Files
Benjamin Admin 65f4ce1947
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 32s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m52s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 18s
feat: ImageLayoutEditor, arrow-key nav, multi-select bold, wider columns
- New ImageLayoutEditor: SVG overlay on original scan with draggable
  column dividers, horizontal guidelines (margins/header/footer),
  double-click to add columns, x-button to delete
- GridTable: MIN_COL_WIDTH 40→80px for better readability
- Arrow up/down keys navigate between rows in the grid editor
- Ctrl+Click for multi-cell selection, Ctrl+B to toggle bold on selection
- getAdjacentCell works for cells that don't exist yet (new rows/cols)
- deleteColumn now merges x-boundaries correctly
- Session restore fix: grid_editor_result/structure_result in session GET
- Footer row 3-state cycle, auto-create cells for empty footer rows
- Grid save/build/GT-mark now advance current_step=11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 07:45:39 +01:00

387 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, string> = {
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<string, string> = {
top_margin: 'Rand oben',
header_bottom: 'Kopfzeile',
footer_top: 'Fusszeile',
bottom_margin: 'Rand unten',
}
const HORIZ_DEFAULTS: Record<string, number> = {
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<HTMLDivElement>(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<typeof draggingRef.current>,
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 (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Layout-Editor
</span>
<div className="flex items-center gap-2">
<button
onClick={() => onZoomChange(Math.max(50, zoom - 25))}
className="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
>
-
</button>
<span className="text-xs text-gray-500 dark:text-gray-400 w-10 text-center">
{zoom}%
</span>
<button
onClick={() => onZoomChange(Math.min(300, zoom + 25))}
className="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
>
+
</button>
<button
onClick={() => onZoomChange(100)}
className="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
>
Fit
</button>
</div>
</div>
{/* Horizontal line toggles */}
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/30 flex-wrap">
{Object.entries(HORIZ_LABELS).map(([key, label]) => {
const isActive = horizontals[key as keyof typeof horizontals] != null
return (
<button
key={key}
onClick={() => toggleHorizontal(key)}
className={`px-2 py-0.5 text-[10px] rounded border transition-colors ${
isActive
? 'font-medium'
: 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'
}`}
style={
isActive
? {
color: HORIZ_COLORS[key],
borderColor: HORIZ_COLORS[key] + '80',
}
: undefined
}
>
{label}
</button>
)
})}
<span className="text-[10px] text-gray-400 dark:text-gray-500 ml-auto">
Doppelklick = Spalte einfuegen
</span>
</div>
{/* Scrollable image with SVG overlay */}
<div className="flex-1 overflow-auto p-2">
<div
ref={wrapperRef}
style={{ width: `${zoom}%`, position: 'relative', maxWidth: 'none' }}
onDoubleClick={handleDoubleClick}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="Original scan"
style={{ width: '100%', display: 'block' }}
draggable={false}
/>
{/* SVG overlay */}
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* 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 (
<g key={`z${zone.zone_index}-b${bi}`}>
{/* Wide invisible hit area */}
<rect
x={xPct - 0.8}
y={yTop}
width={1.6}
height={yBottom - yTop}
fill="transparent"
style={{ cursor: 'col-resize', pointerEvents: 'all' }}
onMouseDown={(e) =>
startDrag(
{ type: 'col', zoneIndex: zone.zone_index, boundaryIndex: bi },
e,
)
}
/>
{/* Visible line */}
<line
x1={xPct}
y1={yTop}
x2={xPct}
y2={yBottom}
stroke={isEdge ? 'rgba(20, 184, 166, 0.35)' : 'rgba(20, 184, 166, 0.7)'}
strokeWidth={isEdge ? 0.15 : 0.25}
strokeDasharray={isEdge ? '0.8,0.4' : '0.5,0.3'}
style={{ pointerEvents: 'none' }}
/>
{/* Delete button for interior dividers */}
{isInterior && zone.columns.length > 1 && (
<g
style={{ pointerEvents: 'all', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation()
onDeleteColumn(zone.zone_index, bi)
}}
>
<circle
cx={xPct}
cy={Math.max(yTop + 1.5, 1.5)}
r={1.2}
fill="rgba(239, 68, 68, 0.8)"
/>
<text
x={xPct}
y={Math.max(yTop + 1.5, 1.5) + 0.5}
textAnchor="middle"
fill="white"
fontSize="1.4"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
x
</text>
</g>
)}
</g>
)
}),
)}
{/* 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 (
<g key={`horiz-${key}`}>
{/* Wide invisible hit area */}
<rect
x={0}
y={yPct - 0.6}
width={100}
height={1.2}
fill="transparent"
style={{ cursor: 'row-resize', pointerEvents: 'all' }}
onMouseDown={(e) => startDrag({ type: 'horiz', key }, e)}
/>
{/* Visible line */}
<line
x1={0}
y1={yPct}
x2={100}
y2={yPct}
stroke={color}
strokeWidth={0.2}
strokeDasharray="1,0.5"
style={{ pointerEvents: 'none' }}
/>
{/* Label */}
<text
x={1}
y={yPct - 0.5}
fill={color}
fontSize="1.6"
style={{ pointerEvents: 'none' }}
>
{HORIZ_LABELS[key]}
</text>
</g>
)
})}
</svg>
</div>
</div>
</div>
)
}