trans3 / ui /components /panels /TextBlocksPanel.tsx
Mayo
fix: per-block translate
01d8941 unverified
'use client'
import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import { DraftTextarea } from '@/components/ui/draft-textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCurrentPage, useTextNodes, type TextNodeEntry } from '@/hooks/useCurrentPage'
import { getConfig, startPipeline, useGetCurrentLlm } from '@/lib/api/default/default'
import type { TextDataPatch } from '@/lib/api/schemas'
import { applyOp, queueAutoRender } from '@/lib/io/scene'
import { ops } from '@/lib/ops'
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
import { useJobsStore } from '@/lib/stores/jobsStore'
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
import { useSelectionStore } from '@/lib/stores/selectionStore'
export function TextBlocksPanel() {
const { t } = useTranslation()
const page = useCurrentPage()
const textNodes = useTextNodes()
const selectedIds = useSelectionStore((s) => s.nodeIds)
const select = useSelectionStore((s) => s.select)
const clearSelection = useSelectionStore((s) => s.clear)
const { data: llm } = useGetCurrentLlm()
const llmReady = llm?.status === 'ready'
const isProcessing = useJobsStore((s) =>
Object.values(s.jobs).some((j) => j.status === 'running'),
)
if (!page) {
return (
<div className='flex flex-1 items-center justify-center text-xs text-muted-foreground'>
{t('textBlocks.emptyPrompt')}
</div>
)
}
const selectedIndex = textNodes.findIndex((n) => selectedIds.has(n.id))
const accordionValue = selectedIndex >= 0 ? selectedIndex.toString() : ''
const patchText = async (nodeId: string, patch: TextDataPatch) => {
await applyOp(
ops.updateNode(page.id, nodeId, {
data: { text: patch } as never,
}),
)
queueAutoRender(page.id)
}
const removeNode = async (nodeId: string) => {
const node = page.nodes[nodeId]
if (!node) return
const idx = Object.keys(page.nodes).indexOf(nodeId)
await applyOp(ops.removeNode(page.id, nodeId, node, idx < 0 ? 0 : idx))
clearSelection()
queueAutoRender(page.id)
}
const generate = async (nodeId: string) => {
if (!page) return
const cfg = await getConfig()
const translator = cfg.pipeline?.translator || 'llm'
const renderer = cfg.pipeline?.renderer || 'koharu-renderer'
const editor = useEditorUiStore.getState()
const prefs = usePreferencesStore.getState()
// Keep rendering page-scoped, but constrain translation to the clicked block.
await startPipeline({
steps: [translator, renderer],
pages: [page.id],
textNodeIds: [nodeId],
targetLanguage: editor.selectedLanguage,
systemPrompt: prefs.customSystemPrompt,
defaultFont: prefs.defaultFont,
})
}
return (
<div className='flex min-h-0 flex-1 flex-col' data-testid='panels-textblocks'>
<div className='flex items-center justify-between border-b border-border px-2 py-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase'>
<span data-testid='textblocks-count' data-count={textNodes.length}>
{t('textBlocks.title', { count: textNodes.length })}
</span>
</div>
<ScrollArea
className='min-h-0 flex-1'
viewportClassName='pb-1'
data-testid='textblocks-scroll'
>
<div className='p-2'>
{textNodes.length === 0 ? (
<p className='rounded-md border border-dashed border-border p-2 text-xs text-muted-foreground'>
{t('textBlocks.none')}
</p>
) : (
<Accordion
data-testid='textblocks-accordion'
type='single'
collapsible
value={accordionValue}
onValueChange={(value) => {
if (!value) {
clearSelection()
return
}
const idx = Number(value)
const node = textNodes[idx]
if (node) select(node.id, false)
}}
className='flex flex-col gap-1'
>
{textNodes.map((node, index) => (
<BlockCard
key={node.id}
node={node}
index={index}
selected={selectedIds.has(node.id)}
onToggleSelect={() => select(node.id, true)}
onPatch={(patch) => void patchText(node.id, patch)}
onDelete={() => void removeNode(node.id)}
onGenerate={() => void generate(node.id)}
processing={isProcessing}
llmReady={llmReady}
/>
))}
</Accordion>
)}
</div>
</ScrollArea>
</div>
)
}
type BlockCardProps = {
node: TextNodeEntry
index: number
selected: boolean
onToggleSelect: () => void
onPatch: (patch: TextDataPatch) => void
onDelete: () => void
onGenerate: () => void
processing: boolean
llmReady: boolean
}
function BlockCard({
node,
index,
selected,
onToggleSelect,
onPatch,
onDelete,
onGenerate,
processing,
llmReady,
}: BlockCardProps) {
const { t } = useTranslation()
const data = node.data
const hasOcr = !!data.text?.trim()
const hasTranslation = !!data.translation?.trim()
const preview = data.translation?.trim() || data.text?.trim()
return (
<motion.div
data-testid={`textblock-card-${index}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
>
<AccordionItem
value={index.toString()}
data-selected={selected}
className='overflow-hidden rounded-md bg-card/90 text-xs ring-1 ring-border data-[selected=true]:ring-primary'
>
<AccordionTrigger
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault()
e.stopPropagation()
onToggleSelect()
}
}}
className='flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left transition outline-none hover:no-underline data-[state=open]:bg-accent [&>svg]:hidden'
>
<span
className={`shrink-0 rounded-md px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
selected ? 'bg-primary' : 'bg-muted-foreground/60'
}`}
style={{ minWidth: '1.5rem' }}
>
{index + 1}
</span>
<div className='flex min-w-0 flex-1 items-center gap-1'>
<span
className={`shrink-0 rounded-sm px-1 py-0.5 text-[9px] font-medium uppercase ${
hasOcr ? 'bg-rose-400/70 text-white' : 'bg-muted text-muted-foreground/50'
}`}
>
{t('textBlocks.ocrBadge')}
</span>
<span
className={`shrink-0 rounded-sm px-1 py-0.5 text-[9px] font-medium uppercase ${
hasTranslation ? 'bg-rose-400/70 text-white' : 'bg-muted text-muted-foreground/50'
}`}
>
{t('textBlocks.translationBadge')}
</span>
{preview && (
<p className='line-clamp-1 min-w-0 flex-1 text-xs text-muted-foreground'>{preview}</p>
)}
</div>
</AccordionTrigger>
<AccordionContent className='px-2 pt-1.5 pb-2 shadow-[inset_0_1px_0_0_var(--color-border)]'>
<div className='space-y-1.5'>
<div className='flex flex-col gap-0.5'>
<span className='text-[10px] text-muted-foreground uppercase'>
{t('textBlocks.ocrLabel')}
</span>
<DraftTextarea
data-testid={`textblock-ocr-${index}`}
value={data.text ?? ''}
placeholder={t('textBlocks.addOcrPlaceholder')}
rows={2}
onValueChange={(value) => onPatch({ text: value })}
className='min-h-0 resize-none px-1.5 py-1 text-xs'
/>
</div>
<div className='flex flex-col gap-0.5'>
<div className='flex items-center justify-between'>
<span className='text-[10px] text-muted-foreground uppercase'>
{t('textBlocks.translationLabel')}
</span>
<div className='flex items-center gap-0.5'>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid={`textblock-delete-${index}`}
aria-label={t('workspace.deleteBlock')}
variant='ghost'
size='icon-xs'
disabled={processing}
onClick={onDelete}
className='size-5 text-rose-600 hover:text-rose-600'
>
<Trash2Icon className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='left' sideOffset={4}>
{t('workspace.deleteBlock')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid={`textblock-generate-${index}`}
variant='ghost'
size='icon-xs'
disabled={!llmReady || processing}
onClick={onGenerate}
className='size-5'
>
{processing ? (
<LoaderCircleIcon className='size-3 animate-spin' />
) : (
<Languages className='size-3' />
)}
</Button>
</TooltipTrigger>
<TooltipContent side='left' sideOffset={4}>
{t('llm.generateTooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
<DraftTextarea
data-testid={`textblock-translation-${index}`}
value={data.translation ?? ''}
placeholder={t('textBlocks.addTranslationPlaceholder')}
rows={2}
onValueChange={(value) => onPatch({ translation: value })}
className='min-h-0 resize-none px-1.5 py-1 text-xs'
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</motion.div>
)
}