Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
9.4 KiB
TypeScript
282 lines
9.4 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* AnnotationLayer
|
|
*
|
|
* SVG overlay component for displaying and creating annotations on documents.
|
|
* Renders positioned rectangles with color-coding by annotation type.
|
|
*/
|
|
|
|
import { useState, useRef, useCallback } from 'react'
|
|
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
|
|
import { ANNOTATION_COLORS } from '../types'
|
|
|
|
interface AnnotationLayerProps {
|
|
annotations: Annotation[]
|
|
selectedTool: AnnotationType | null
|
|
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
|
onSelectAnnotation: (annotation: Annotation) => void
|
|
selectedAnnotationId?: string
|
|
disabled?: boolean
|
|
}
|
|
|
|
export default function AnnotationLayer({
|
|
annotations,
|
|
selectedTool,
|
|
onCreateAnnotation,
|
|
onSelectAnnotation,
|
|
selectedAnnotationId,
|
|
disabled = false,
|
|
}: AnnotationLayerProps) {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const [isDrawing, setIsDrawing] = useState(false)
|
|
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
|
|
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
|
|
|
|
// Convert mouse position to percentage
|
|
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
|
if (!svgRef.current) return null
|
|
|
|
const rect = svgRef.current.getBoundingClientRect()
|
|
const x = ((e.clientX - rect.left) / rect.width) * 100
|
|
const y = ((e.clientY - rect.top) / rect.height) * 100
|
|
|
|
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
|
|
}, [])
|
|
|
|
// Handle mouse down - start drawing
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent<SVGSVGElement>) => {
|
|
if (disabled || !selectedTool) return
|
|
|
|
const pos = getPercentPosition(e)
|
|
if (!pos) return
|
|
|
|
setIsDrawing(true)
|
|
setStartPos(pos)
|
|
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
|
|
},
|
|
[disabled, selectedTool, getPercentPosition]
|
|
)
|
|
|
|
// Handle mouse move - update rectangle
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent<SVGSVGElement>) => {
|
|
if (!isDrawing || !startPos) return
|
|
|
|
const pos = getPercentPosition(e)
|
|
if (!pos) return
|
|
|
|
const x = Math.min(startPos.x, pos.x)
|
|
const y = Math.min(startPos.y, pos.y)
|
|
const width = Math.abs(pos.x - startPos.x)
|
|
const height = Math.abs(pos.y - startPos.y)
|
|
|
|
setCurrentRect({ x, y, width, height })
|
|
},
|
|
[isDrawing, startPos, getPercentPosition]
|
|
)
|
|
|
|
// Handle mouse up - finish drawing
|
|
const handleMouseUp = useCallback(() => {
|
|
if (!isDrawing || !currentRect || !selectedTool) {
|
|
setIsDrawing(false)
|
|
setStartPos(null)
|
|
setCurrentRect(null)
|
|
return
|
|
}
|
|
|
|
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
|
|
if (currentRect.width > 1 && currentRect.height > 0.5) {
|
|
onCreateAnnotation(currentRect, selectedTool)
|
|
}
|
|
|
|
setIsDrawing(false)
|
|
setStartPos(null)
|
|
setCurrentRect(null)
|
|
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
|
|
|
|
// Handle clicking on existing annotation
|
|
const handleAnnotationClick = useCallback(
|
|
(e: React.MouseEvent, annotation: Annotation) => {
|
|
e.stopPropagation()
|
|
onSelectAnnotation(annotation)
|
|
},
|
|
[onSelectAnnotation]
|
|
)
|
|
|
|
return (
|
|
<svg
|
|
ref={svgRef}
|
|
className={`absolute inset-0 w-full h-full ${
|
|
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
|
|
}`}
|
|
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
{/* SVG Defs for patterns */}
|
|
<defs>
|
|
{/* Wavy pattern for Rechtschreibung errors */}
|
|
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
|
|
<path
|
|
d="M0 2 Q 2.5 0, 5 2 T 10 2"
|
|
stroke="#dc2626"
|
|
strokeWidth="1.5"
|
|
fill="none"
|
|
/>
|
|
</pattern>
|
|
{/* Straight underline pattern for Grammatik errors */}
|
|
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
|
|
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
|
|
</pattern>
|
|
</defs>
|
|
|
|
{/* Existing annotations */}
|
|
{annotations.map((annotation) => {
|
|
const isSelected = annotation.id === selectedAnnotationId
|
|
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
|
const isRS = annotation.type === 'rechtschreibung'
|
|
const isGram = annotation.type === 'grammatik'
|
|
|
|
return (
|
|
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
|
|
{/* Background rectangle - different styles for RS/Gram */}
|
|
{isRS || isGram ? (
|
|
<>
|
|
{/* Light highlight background */}
|
|
<rect
|
|
x={`${annotation.position.x}%`}
|
|
y={`${annotation.position.y}%`}
|
|
width={`${annotation.position.width}%`}
|
|
height={`${annotation.position.height}%`}
|
|
fill={color}
|
|
fillOpacity={isSelected ? 0.25 : 0.15}
|
|
className="cursor-pointer hover:fill-opacity-25 transition-all"
|
|
/>
|
|
{/* Underline - wavy for RS, straight for Gram */}
|
|
<rect
|
|
x={`${annotation.position.x}%`}
|
|
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
|
|
width={`${annotation.position.width}%`}
|
|
height="0.5%"
|
|
fill={isRS ? 'url(#wavyPattern)' : color}
|
|
stroke="none"
|
|
/>
|
|
{/* Border when selected */}
|
|
{isSelected && (
|
|
<rect
|
|
x={`${annotation.position.x}%`}
|
|
y={`${annotation.position.y}%`}
|
|
width={`${annotation.position.width}%`}
|
|
height={`${annotation.position.height}%`}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={2}
|
|
strokeDasharray="4,2"
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
/* Standard rectangle for other annotation types */
|
|
<rect
|
|
x={`${annotation.position.x}%`}
|
|
y={`${annotation.position.y}%`}
|
|
width={`${annotation.position.width}%`}
|
|
height={`${annotation.position.height}%`}
|
|
fill={color}
|
|
fillOpacity={0.2}
|
|
stroke={color}
|
|
strokeWidth={isSelected ? 3 : 2}
|
|
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
|
|
className="cursor-pointer hover:fill-opacity-30 transition-all"
|
|
rx="2"
|
|
/>
|
|
)}
|
|
|
|
{/* Type indicator icon (small circle in corner) */}
|
|
<circle
|
|
cx={`${annotation.position.x}%`}
|
|
cy={`${annotation.position.y}%`}
|
|
r="6"
|
|
fill={color}
|
|
stroke="white"
|
|
strokeWidth="1"
|
|
/>
|
|
|
|
{/* Type letter */}
|
|
<text
|
|
x={`${annotation.position.x}%`}
|
|
y={`${annotation.position.y}%`}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
fill="white"
|
|
fontSize="8"
|
|
fontWeight="bold"
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
{annotation.type.charAt(0).toUpperCase()}
|
|
</text>
|
|
|
|
{/* Severity indicator (small dot) */}
|
|
{annotation.severity === 'critical' && (
|
|
<circle
|
|
cx={`${annotation.position.x + annotation.position.width}%`}
|
|
cy={`${annotation.position.y}%`}
|
|
r="4"
|
|
fill="#dc2626"
|
|
stroke="white"
|
|
strokeWidth="1"
|
|
/>
|
|
)}
|
|
|
|
{/* Selection indicator */}
|
|
{isSelected && (
|
|
<>
|
|
{/* Corner handles */}
|
|
{[
|
|
{ cx: annotation.position.x, cy: annotation.position.y },
|
|
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
|
|
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
|
|
{
|
|
cx: annotation.position.x + annotation.position.width,
|
|
cy: annotation.position.y + annotation.position.height,
|
|
},
|
|
].map((corner, i) => (
|
|
<circle
|
|
key={i}
|
|
cx={`${corner.cx}%`}
|
|
cy={`${corner.cy}%`}
|
|
r="4"
|
|
fill="white"
|
|
stroke={color}
|
|
strokeWidth="2"
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</g>
|
|
)
|
|
})}
|
|
|
|
{/* Currently drawing rectangle */}
|
|
{currentRect && selectedTool && (
|
|
<rect
|
|
x={`${currentRect.x}%`}
|
|
y={`${currentRect.y}%`}
|
|
width={`${currentRect.width}%`}
|
|
height={`${currentRect.height}%`}
|
|
fill={ANNOTATION_COLORS[selectedTool]}
|
|
fillOpacity={0.3}
|
|
stroke={ANNOTATION_COLORS[selectedTool]}
|
|
strokeWidth={2}
|
|
strokeDasharray="5,5"
|
|
rx="2"
|
|
/>
|
|
)}
|
|
</svg>
|
|
)
|
|
}
|