Files
hejyou_content_creation/frontend/src/pages/DrawIt.tsx
admin e18d9a5796 bbox/polygon durch selections ersetzen
- bbox und polygon Felder in Directus versteckt (Daten bleiben)
- Alle Auswahlen laufen nur noch über das selections-Feld
- CanvasObject, DirectusObject, API und Zeichenlogik umgestellt
- Objekte mit mehreren Auswahlen werden korrekt auf Canvas gezeichnet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:36:22 +02:00

348 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import Topbar from '../components/Topbar'
import {
getDirectusPictures, directusAssetUrl, type DirectusPicture,
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, Selection, CanvasObject } from '../types'
const ChevronLeftIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
)
const ChevronRightIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
)
const TrashIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
)
export default function DrawIt() {
const { token } = useAuth()
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [objects, setObjects] = useState<DirectusObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [userNotes, setUserNotes] = useState('')
const [parentId, setParentId] = useState<string | null>(null)
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
const [hasSelection, setHasSelection] = useState(false)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState('')
const [statusError, setStatusError] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null)
const currentPicture: DirectusPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
// Map DirectusObject → CanvasObject for rendering
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
id: obj.id,
visible: obj.visible !== false,
selections: obj.selections,
index: i + 1,
hierarchy: 1,
}))
useEffect(() => {
if (!token) return
getDirectusPictures(token)
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
useEffect(() => {
if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null); return }
getDirectusObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
setStatus(msg); setStatusError(isError)
}
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
const addSelection = () => {
const sel = canvasRef.current?.getCurrentSelection()
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
setCurrentSelections(prev => { const next = [...prev, sel]; showStatus(`Auswahl ${next.length} hinzugefügt.`); return next })
canvasRef.current?.resetSelection()
setHasSelection(false)
}
const saveObject = async () => {
if (!currentPicture || !token || currentSelections.length === 0) return
setSaving(true)
try {
const obj = await createDirectusObject({
picture: currentPicture.id,
selections: currentSelections,
user_notes: userNotes.trim() || null,
parent: parentId,
}, token)
setObjects(prev => [...prev, { ...obj, visible: true }])
setCurrentSelections([])
setUserNotes('')
setParentId(null)
canvasRef.current?.resetSelection()
showStatus('Objekt gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
} finally {
setSaving(false)
}
}
const saveNoteEdit = async () => {
if (!editingNotes || !token) return
try {
await updateDirectusObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o))
setEditingNotes(null)
showStatus('Notizen gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
}
}
const deleteObject = async (objId: string) => {
if (!token) return
try {
await deleteDirectusObject(objId, token)
setObjects(prev => prev.filter(o => o.id !== objId))
if (selectedObjectId === objId) setSelectedObjectId(null)
showStatus('Objekt gelöscht.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Löschen.', true)
}
}
const imageNav = (
<div className="image-nav">
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
<ChevronLeftIcon />
</button>
<span className="image-counter">
{pictureList.length > 0
? <><span className="image-counter-num">{currentIndex + 1}</span><span className="image-counter-sep">/</span><span className="image-counter-total">{pictureList.length}</span></>
: <span className="image-counter-empty">Keine Bilder</span>}
</span>
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
<ChevronRightIcon />
</button>
</div>
)
return (
<div className="app-shell">
<Topbar page="draw" center={imageNav} />
<div className="workspace">
{/* Left sidebar: saved objects */}
<aside className="sidebar">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Objekte
{objects.length > 0 && <span className="badge">{objects.length}</span>}
</h3>
{objects.length === 0 ? (
<div className="empty-state">Noch keine Objekte für dieses Bild.</div>
) : (
<div className="objects-list">
{objects.map((obj, i) => (
<div
key={obj.id}
className={`object-item${selectedObjectId === obj.id ? ' selected' : ''}`}
onClick={() => setSelectedObjectId(obj.id === selectedObjectId ? null : obj.id)}
>
<div className="object-item-header">
<input
type="checkbox"
checked={obj.visible !== false}
onClick={e => e.stopPropagation()}
onChange={e => setObjects(prev => prev.map(o => o.id === obj.id ? { ...o, visible: e.target.checked } : o))}
/>
<div className="object-item-text">
<strong>Objekt {i + 1}</strong>
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : ''}</span>
{obj.parent && <span style={{ color: 'var(--primary-muted-fg)', fontSize: 11 }}> Kind von #{objects.findIndex(o => o.id === obj.parent) + 1}</span>}
</div>
<button
className="object-icon-button"
onClick={e => { e.stopPropagation(); setEditingNotes({ id: obj.id, notes: obj.user_notes ?? '' }) }}
title="Notizen bearbeiten"
></button>
<button
className="object-icon-button"
onClick={e => { e.stopPropagation(); deleteObject(obj.id) }}
title="Löschen"
><TrashIcon /></button>
</div>
{obj.user_notes && (
<div style={{ padding: '4px 8px 6px 32px', fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.4, borderTop: '1px solid var(--border)' }}>
{obj.user_notes}
</div>
)}
{editingNotes?.id === obj.id && (
<div style={{ padding: '8px', borderTop: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 6 }} onClick={e => e.stopPropagation()}>
<textarea
value={editingNotes.notes}
onChange={e => setEditingNotes({ ...editingNotes, notes: e.target.value })}
rows={3}
style={{ width: '100%', resize: 'vertical', padding: '6px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12 }}
placeholder="Notizen zum Objekt…"
autoFocus
/>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn-primary btn-sm" style={{ flex: 1 }} onClick={saveNoteEdit}>Speichern</button>
<button className="btn-ghost btn-sm" onClick={() => setEditingNotes(null)}>Abbrechen</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</aside>
{/* Center: Canvas */}
<main className="canvas-area">
<div className="canvas-frame">
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjectId}
mode={mode}
onHasSelection={handleHasSelection}
/>
</div>
</main>
{/* Right sidebar: drawing tools */}
<aside className="sidebar sidebar--right">
<div className="sidebar-panel">
<h3 className="sidebar-heading">Modus</h3>
<div className="mode-group">
<label className={`mode-btn${mode === 'rect' ? ' active' : ''}`}>
<input type="radio" name="mode" value="rect" checked={mode === 'rect'} onChange={() => setMode('rect')} />
<span>Rechteck</span>
</label>
<label className={`mode-btn${mode === 'polygon' ? ' active' : ''}`}>
<input type="radio" name="mode" value="polygon" checked={mode === 'polygon'} onChange={() => setMode('polygon')} />
<span>Polygon</span>
</label>
</div>
<div className="action-group">
{mode === 'polygon' && (
<button
className="btn-primary btn-sm btn-block"
onClick={() => canvasRef.current?.closePolygon()}
disabled={!hasSelection}
>
Polygon schließen
</button>
)}
<button className="btn-ghost btn-sm btn-block" onClick={() => canvasRef.current?.resetSelection()}>
Auswahl zurücksetzen
</button>
</div>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">Notizen zum Objekt</h3>
<textarea
value={userNotes}
onChange={e => setUserNotes(e.target.value)}
rows={4}
placeholder="Beschreibung, Besonderheiten, Kontext…"
style={{
width: '100%', resize: 'vertical', padding: '7px 10px',
borderRadius: 'var(--r-md)', border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-1)',
fontFamily: 'var(--font)', fontSize: 13, lineHeight: 1.5,
transition: 'border-color .15s, box-shadow .15s', outline: 'none',
}}
onFocus={e => { e.target.style.borderColor = 'var(--primary)'; e.target.style.boxShadow = '0 0 0 3px rgba(92,108,246,.12)' }}
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
/>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">Parent-Objekt</h3>
<select
value={parentId ?? ''}
onChange={e => setParentId(e.target.value || null)}
>
<option value=""> kein Parent </option>
{objects.map((obj, i) => (
<option key={obj.id} value={obj.id}>
Objekt {i + 1}{obj.user_notes ? ` ${obj.user_notes.slice(0, 30)}` : ''}
</option>
))}
</select>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Auswahlen
{currentSelections.length > 0 && <span className="badge">{currentSelections.length}</span>}
</h3>
<div className="selections-list">
{currentSelections.length === 0
? <div className="empty-state">Noch keine Auswahlen</div>
: currentSelections.map((sel, i) => (
<div className="selection-chip" key={i}>
<span className="selection-chip-num">{i + 1}</span>
<span className="selection-chip-type">{sel.mode === 'rect' ? 'Rect' : 'Poly'}</span>
<span className="selection-chip-info">
{sel.mode === 'rect' && sel.bbox
? `${Math.round(sel.bbox.width)}×${Math.round(sel.bbox.height)}`
: `${sel.polygon?.length ?? 0} Pkt.`}
</span>
</div>
))}
</div>
<div className="action-group">
<button className="btn-primary btn-sm btn-block" onClick={addSelection} disabled={!hasSelection || !currentPicture}>
+ Auswahl hinzufügen
</button>
<button
className="btn-primary btn-sm btn-block"
onClick={saveObject}
disabled={!currentPicture || currentSelections.length === 0 || saving}
>
{saving ? 'Speichere…' : `${currentSelections.length > 1 ? currentSelections.length + ' Objekte' : 'Objekt'} speichern`}
</button>
<button
className="btn-ghost btn-sm btn-block btn-danger"
onClick={() => { setCurrentSelections([]); canvasRef.current?.resetSelection(); showStatus('Alle Auswahlen gelöscht.') }}
>
Alle löschen
</button>
</div>
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
</div>
</aside>
</div>
</div>
)
}