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