emojinize / frontend /src /components /AnnotationResult.tsx
Luturtun's picture
Replace models: new encoder + gemma-3-1b/qwen3 decoders, drop llama3
34cc2e8
Raw
History Blame Contribute Delete
9.61 kB
'use client'
import { useState } from 'react'
import type { AnnotateResponse, AnnotationEntry } from '@/lib/api'
import type { Settings } from '@/types'
interface Sentence {
text: string
annotations: AnnotationEntry[]
startAnnotIdx: number
}
const CHARS_PER_PAGE = 2000
interface Props {
result: AnnotateResponse
settings: Settings
}
export default function AnnotationResult({ result, settings }: Props) {
const [copied, setCopied] = useState<'annotated' | 'replaced' | null>(null)
const [toggled, setToggled] = useState<Set<number>>(new Set())
const [page, setPage] = useState(0)
if (!result.valid || !result.annotation) {
return (
<div className="bg-red-50 border border-red-200 rounded-2xl p-5 text-sm text-red-700">
<span className="font-semibold">Annotation failed: </span>
{result.reason ?? 'Unknown error.'}
</div>
)
}
const { marked, annotations } = result.annotation
const sentences = parseSentences(marked, annotations)
const pages = groupByChars(sentences, CHARS_PER_PAGE)
const totalPages = Math.max(1, pages.length)
const safePage = Math.min(page, totalPages - 1)
const pageSentences = pages[safePage] ?? []
const lineHeight =
settings.displayMode === 'top'
? `${settings.emojiSize + 44}px`
: `${Math.max(32, settings.emojiSize * 1.4)}px`
function handleCopy(type: 'annotated' | 'replaced') {
const text =
type === 'annotated'
? buildAnnotatedText(marked, annotations)
: buildReplacedText(marked, annotations)
navigator.clipboard.writeText(text).then(() => {
setCopied(type)
setTimeout(() => setCopied(null), 2000)
})
}
function handleToggle(globalIdx: number) {
setToggled((prev) => {
const next = new Set(prev)
if (next.has(globalIdx)) next.delete(globalIdx)
else next.add(globalIdx)
return next
})
}
return (
<div className="bg-white/80 dark:bg-black/40 backdrop-blur-md dark:backdrop-blur-xl border border-gray-300 dark:border-white/10 rounded-2xl shadow-md p-6">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-gray-300 dark:border-gray-600">
<p className="text-sm font-bold text-gray-600 dark:text-white uppercase tracking-widest">
Result
</p>
<div className="flex items-center gap-3">
<CopyButton label="Copy annotated" done={copied === 'annotated'} onClick={() => handleCopy('annotated')} />
<CopyButton label="Copy replaced" done={copied === 'replaced'} onClick={() => handleCopy('replaced')} />
</div>
</div>
{/* Text */}
<p className="text-[16px] mt-4" style={{ lineHeight }}>
{pageSentences.map((sentence, si) => (
<span key={si}>
{settings.displayMode === 'top'
? renderTop(sentence, settings.emojiSize, si)
: renderToggle(sentence, settings.emojiSize, toggled, handleToggle, si)}
{si < pageSentences.length - 1 ? ' ' : ''}
</span>
))}
</p>
{/* Pagination — always visible */}
<div className="flex items-center justify-end gap-4 mt-5 pt-4 border-t border-gray-300 dark:border-gray-600">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={safePage === 0}
className="px-3 py-1.5 text-[15px] font-semibold text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
← Prev
</button>
<span className="text-[15px] font-semibold text-gray-600 dark:text-gray-400 tabular-nums">
{safePage + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={safePage === totalPages - 1}
className="px-3 py-1.5 text-[15px] font-semibold text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next →
</button>
</div>
</div>
)
}
function groupByChars(sentences: Sentence[], maxChars: number): Sentence[][] {
const pages: Sentence[][] = []
let current: Sentence[] = []
let count = 0
for (const s of sentences) {
const len = s.text.replace(/<\/?span>/g, '').length
if (current.length > 0 && count + len > maxChars) {
pages.push(current)
current = [s]
count = len
} else {
current.push(s)
count += len
}
}
if (current.length > 0) pages.push(current)
return pages
}
function parseSentences(marked: string, annotations: AnnotationEntry[]): Sentence[] {
const parts = marked.split(/(?<=[.!?])\s+/)
let globalAnnotIdx = 0
return parts.map((text) => {
const spanCount = (text.match(/<span>/g) ?? []).length
const sentence: Sentence = {
text: text.trim(),
annotations: annotations.slice(globalAnnotIdx, globalAnnotIdx + spanCount),
startAnnotIdx: globalAnnotIdx,
}
globalAnnotIdx += spanCount
return sentence
})
}
function renderTop(
sentence: Sentence,
emojiSize: number,
prefix: number
): React.ReactNode[] {
const parts = sentence.text.split(/(<span>.*?<\/span>)/g)
let localIdx = 0
return parts.map((part, i) => {
const match = part.match(/^<span>(.*?)<\/span>$/)
if (match) {
const word = match[1]
const emoji = sentence.annotations[localIdx++]?.emojis?.trim() ?? ''
// Empty emoji → render as plain, unmarked text (no highlight, no emoji slot)
if (!emoji) return <span key={`${prefix}-${i}`}>{word}</span>
return (
<span
key={`${prefix}-${i}`}
className="inline-block mx-0.5 text-center"
style={{ verticalAlign: 'bottom' }}
>
<span className="block" style={{ fontSize: emojiSize, lineHeight: 1, marginBottom: -14 }}>
{emoji}
</span>
<span className="bg-yellow-100 dark:bg-yellow-700/50 rounded-sm px-0.5">
{word}
</span>
</span>
)
}
return <span key={`${prefix}-${i}`}>{part}</span>
})
}
function renderToggle(
sentence: Sentence,
emojiSize: number,
toggled: Set<number>,
onToggle: (globalIdx: number) => void,
prefix: number
): React.ReactNode[] {
const parts = sentence.text.split(/(<span>.*?<\/span>)/g)
let localIdx = 0
return parts.map((part, i) => {
const match = part.match(/^<span>(.*?)<\/span>$/)
if (match) {
const word = match[1]
const globalIdx = sentence.startAnnotIdx + localIdx
const emoji = sentence.annotations[localIdx++]?.emojis?.trim() ?? ''
// Empty emoji → render as plain, unmarked text (no highlight, no toggle)
if (!emoji) return <span key={`${prefix}-${i}`}>{word}</span>
const isToggled = toggled.has(globalIdx)
return (
<button
key={`${prefix}-${i}`}
onClick={() => onToggle(globalIdx)}
title={isToggled ? word : emoji}
className="inline-block mx-0.5 bg-yellow-100 dark:bg-yellow-700/50 rounded-sm px-0.5 cursor-pointer align-baseline hover:opacity-70 transition-opacity"
>
{isToggled ? (
<span style={{ fontSize: emojiSize }}>{emoji}</span>
) : (
word
)}
</button>
)
}
return <span key={`${prefix}-${i}`}>{part}</span>
})
}
function buildAnnotatedText(marked: string, annotations: AnnotationEntry[]): string {
const parts = marked.split(/(<span>.*?<\/span>)/g)
let i = 0
return parts
.map((part) => {
const match = part.match(/^<span>(.*?)<\/span>$/)
if (match) {
const emoji = annotations[i++]?.emojis.trim() ?? ''
// Empty emoji → plain word, no empty [](...) brackets
return emoji ? `[${match[1]}](${emoji})` : match[1]
}
return part
})
.join('')
}
function buildReplacedText(marked: string, annotations: AnnotationEntry[]): string {
const parts = marked.split(/(<span>.*?<\/span>)/g)
let i = 0
return parts
.map((part) => {
const match = part.match(/^<span>(.*?)<\/span>$/)
if (match) {
const emoji = annotations[i++]?.emojis.trim() ?? ''
// Empty emoji → keep the original word (nothing to replace it with)
return emoji ? emoji : match[1]
}
return part
})
.join('')
}
function CopyButton({ label, done, onClick }: { label: string; done: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
title={label}
className="flex items-center gap-1.5 text-[13px] font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{done ? <CheckIcon /> : <CopyIcon />}
<span>{done ? 'Copied!' : label}</span>
</button>
)
}
function CopyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
)
}
function CheckIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)
}