'use client' /** * ImageLayoutEditor — SVG overlay on the original scan image. * * Shows draggable vertical column dividers and horizontal guidelines * (margins, header/footer zones). Double-click to add a column, * click the × on a divider to remove it. */ import { useCallback, useRef } from 'react' import type { GridZone, LayoutDividers } from './types' interface ImageLayoutEditorProps { imageUrl: string zones: GridZone[] imageWidth: number layoutDividers?: LayoutDividers zoom: number onZoomChange: (zoom: number) => void onColumnDividerMove: (zoneIndex: number, boundaryIndex: number, newXPct: number) => void onHorizontalsChange: (horizontals: LayoutDividers['horizontals']) => void onCommitUndo: () => void onSplitColumnAt: (zoneIndex: number, xPct: number) => void onDeleteColumn: (zoneIndex: number, colIndex: number) => void } const HORIZ_COLORS: Record = { top_margin: 'rgba(239, 68, 68, 0.6)', header_bottom: 'rgba(59, 130, 246, 0.6)', footer_top: 'rgba(249, 115, 22, 0.6)', bottom_margin: 'rgba(239, 68, 68, 0.6)', } const HORIZ_LABELS: Record = { top_margin: 'Rand oben', header_bottom: 'Kopfzeile', footer_top: 'Fusszeile', bottom_margin: 'Rand unten', } const HORIZ_DEFAULTS: Record = { top_margin: 3, header_bottom: 10, footer_top: 92, bottom_margin: 97, } function clamp(val: number, min: number, max: number) { return Math.max(min, Math.min(max, val)) } export function ImageLayoutEditor({ imageUrl, zones, layoutDividers, zoom, onZoomChange, onColumnDividerMove, onHorizontalsChange, onCommitUndo, onSplitColumnAt, onDeleteColumn, }: ImageLayoutEditorProps) { const wrapperRef = useRef(null) const draggingRef = useRef< | { type: 'col'; zoneIndex: number; boundaryIndex: number } | { type: 'horiz'; key: string } | null >(null) const horizontalsRef = useRef(layoutDividers?.horizontals ?? {}) horizontalsRef.current = layoutDividers?.horizontals ?? {} const horizontals = layoutDividers?.horizontals ?? {} // Compute column boundaries for each zone const zoneBoundaries = zones.map((zone) => { const sorted = [...zone.columns].sort((a, b) => a.index - b.index) const boundaries: number[] = [] if (sorted.length > 0) { const hasValidPct = sorted.some((c) => c.x_max_pct > 0) if (hasValidPct) { boundaries.push(sorted[0].x_min_pct) for (const col of sorted) { boundaries.push(col.x_max_pct) } } else { // Fallback: evenly distribute within zone bbox const zoneX = zone.bbox_pct.x || 0 const zoneW = zone.bbox_pct.w || 100 for (let i = 0; i <= sorted.length; i++) { boundaries.push(zoneX + (i / sorted.length) * zoneW) } } } return { zone, boundaries } }) const startDrag = useCallback( ( info: NonNullable, e: React.MouseEvent, ) => { e.preventDefault() e.stopPropagation() draggingRef.current = info onCommitUndo() const handleMove = (ev: MouseEvent) => { const wrap = wrapperRef.current if (!wrap || !draggingRef.current) return const rect = wrap.getBoundingClientRect() const xPct = clamp(((ev.clientX - rect.left) / rect.width) * 100, 0, 100) const yPct = clamp(((ev.clientY - rect.top) / rect.height) * 100, 0, 100) if (draggingRef.current.type === 'col') { onColumnDividerMove( draggingRef.current.zoneIndex, draggingRef.current.boundaryIndex, xPct, ) } else { onHorizontalsChange({ ...horizontalsRef.current, [draggingRef.current.key]: yPct, }) } } const handleUp = () => { draggingRef.current = null document.removeEventListener('mousemove', handleMove) document.removeEventListener('mouseup', handleUp) document.body.style.cursor = '' document.body.style.userSelect = '' } document.body.style.cursor = info.type === 'col' ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' document.addEventListener('mousemove', handleMove) document.addEventListener('mouseup', handleUp) }, [onColumnDividerMove, onHorizontalsChange, onCommitUndo], ) const toggleHorizontal = (key: string) => { const current = horizontals[key as keyof typeof horizontals] if (current != null) { const next = { ...horizontals } delete next[key as keyof typeof next] onHorizontalsChange(next) } else { onCommitUndo() onHorizontalsChange({ ...horizontals, [key]: HORIZ_DEFAULTS[key], }) } } const handleDoubleClick = (e: React.MouseEvent) => { const wrap = wrapperRef.current if (!wrap) return const rect = wrap.getBoundingClientRect() const xPct = clamp(((e.clientX - rect.left) / rect.width) * 100, 0, 100) const yPct = clamp(((e.clientY - rect.top) / rect.height) * 100, 0, 100) // Find which zone this click is in for (const { zone } of zoneBoundaries) { const zy = zone.bbox_pct.y || 0 const zh = zone.bbox_pct.h || 100 if (yPct >= zy && yPct <= zy + zh) { onSplitColumnAt(zone.zone_index, xPct) return } } // Fallback: use first zone if (zones.length > 0) { onSplitColumnAt(zones[0].zone_index, xPct) } } return (
{/* Header */}
Layout-Editor
{zoom}%
{/* Horizontal line toggles */}
{Object.entries(HORIZ_LABELS).map(([key, label]) => { const isActive = horizontals[key as keyof typeof horizontals] != null return ( ) })} Doppelklick = Spalte einfuegen
{/* Scrollable image with SVG overlay */}
{/* eslint-disable-next-line @next/next/no-img-element */} Original scan {/* SVG overlay */} {/* Column boundary lines per zone */} {zoneBoundaries.map(({ zone, boundaries }) => boundaries.map((xPct, bi) => { const yTop = zone.bbox_pct.y || 0 const yBottom = (zone.bbox_pct.y || 0) + (zone.bbox_pct.h || 100) const isEdge = bi === 0 || bi === boundaries.length - 1 const isInterior = bi > 0 && bi < boundaries.length - 1 return ( {/* Wide invisible hit area */} startDrag( { type: 'col', zoneIndex: zone.zone_index, boundaryIndex: bi }, e, ) } /> {/* Visible line */} {/* Delete button for interior dividers */} {isInterior && zone.columns.length > 1 && ( { e.stopPropagation() onDeleteColumn(zone.zone_index, bi) }} > x )} ) }), )} {/* Horizontal guideline lines */} {Object.entries(horizontals).map(([key, yPct]) => { if (yPct == null) return null const color = HORIZ_COLORS[key] ?? 'rgba(156, 163, 175, 0.6)' return ( {/* Wide invisible hit area */} startDrag({ type: 'horiz', key }, e)} /> {/* Visible line */} {/* Label */} {HORIZ_LABELS[key]} ) })}
) }