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