'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 (
{t('textBlocks.emptyPrompt')}
) } 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 (
{t('textBlocks.title', { count: textNodes.length })}
{textNodes.length === 0 ? (

{t('textBlocks.none')}

) : ( { 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) => ( 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} /> ))} )}
) } 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 ( { 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' > {index + 1}
{t('textBlocks.ocrBadge')} {t('textBlocks.translationBadge')} {preview && (

{preview}

)}
{t('textBlocks.ocrLabel')} onPatch({ text: value })} className='min-h-0 resize-none px-1.5 py-1 text-xs' />
{t('textBlocks.translationLabel')}
{t('workspace.deleteBlock')} {t('llm.generateTooltip')}
onPatch({ translation: value })} className='min-h-0 resize-none px-1.5 py-1 text-xs' />
) }