Files
DOSSIER/src/components/SymbolPicker.jsx
T
karim e1b63aa4e6 Library-Thumbnails: Auto-Capture + Base64-Preview in der UI
Beim Hinzufuegen oder Importieren eines Library-Items wird automatisch
ein PNG-Thumbnail vom Item generiert (Top-View, 128x128) und in
library/previews/<id>.png abgelegt. Frontend rendert die Previews als
Base64-Data-URIs (sicher gegen WebKit-file://-Restriktionen).

library.py:
- _previews_dir(): legt previews/-Folder an
- _preview_rel_for(asset_rel): predictable PNG-Pfad pro Item
- _capture_thumbnail_of_objects(): hided andere Objekte temporaer,
  switcht auf Top-Parallel, ZoomBoundingBox, CaptureToBitmap → PNG,
  restored Viewport + Hidden-State
- read_preview_data_uri(): liest PNG + encoded als data:image/png;base64
- Hook in convert_to_3dm_via_import (vor Cleanup) + save_selection_to_asset

rhinopanel.py:
- _enrich_library_items_with_previews(): haengt previewDataUri an
  jedes Item das ein preview-Feld hat
- Initial-Params + _send_library + ElementeBridge._cmd_list_library
  liefern angereicherte Items
- _add_library_file + _save_selection_as_library setzen preview-Pfad
  im Item wenn Thumbnail-Datei existiert

Frontend:
- SymbolPicker.ItemPreview: rendert <div backgroundImage> mit Base64-URI
  wenn vorhanden, sonst Icon-Fallback
- ProjectSettingsDialog Symbole-Tab: List-Row + Detail-Identity zeigen
  Thumbnail (32px in Liste, 56px im Detail), Icon nur als Fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:07:33 +02:00

204 lines
6.7 KiB
React

import { useState, useMemo } from 'react'
import Icon from './Icon'
import { BarToggle, BarButton, BAR_H } from './BarControls'
/* SymbolPicker — Modal-Overlay: zeigt Library-Items (symbol/object) als
Grid. Klick auf Item → onPick(id). Schliesst via onClose. */
function ItemPreview({ item }) {
// Thumbnail wenn vorhanden, sonst type-spezifisches Icon
if (item.previewDataUri) {
return (
<div style={{
height: 80,
background: 'var(--bg-input)',
borderBottom: '1px solid var(--border-light)',
backgroundImage: `url("${item.previewDataUri}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}} />
)
}
const iconName = item.type === 'symbol' ? 'navigation' : 'forest'
return (
<div style={{
height: 80,
background: 'var(--bg-input)',
borderBottom: '1px solid var(--border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={iconName} size={28}
style={{ color: 'var(--text-muted)' }} />
</div>
)
}
function ItemCard({ item, onPick }) {
return (
<button onClick={() => onPick(item.id)}
onDoubleClick={() => onPick(item.id)}
style={{
display: 'flex', flexDirection: 'column',
border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--bg-section)',
overflow: 'hidden',
cursor: 'pointer', padding: 0, textAlign: 'left',
transition: 'border-color 0.15s, transform 0.1s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
}}
title="Klick zum Platzieren"
>
<ItemPreview item={item} />
<div style={{ padding: '6px 8px',
display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{
fontSize: 11, fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>{item.name}</span>
<span style={{ fontSize: 9, color: 'var(--text-muted)',
textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{item.type === 'symbol' ? '2D-Symbol'
: item.type === 'object' ? '2D + 3D'
: item.type}
{((item.tags || []).length > 0) && ' · ' + item.tags.slice(0, 2).join(' · ')}
</span>
</div>
</button>
)
}
export default function SymbolPicker({ items, onPick, onClose, embedded = false }) {
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
// Nur symbol/object — material wird hier nicht gepicked
const placable = useMemo(() =>
(items || []).filter(it =>
it.type === 'symbol' || it.type === 'object'),
[items])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return placable.filter(it => {
if (typeFilter !== 'all' && it.type !== typeFilter) return false
if (!q) return true
if ((it.name || '').toLowerCase().includes(q)) return true
if ((it.tags || []).some(t => t.toLowerCase().includes(q))) return true
return false
})
}, [placable, search, typeFilter])
const innerStyle = embedded ? {
width: '100%', height: '100%',
background: 'var(--bg-dialog)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
} : {
width: '90%', maxWidth: 640, maxHeight: '80vh',
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 12,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
}
const content = (
<div style={innerStyle}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="inventory_2" size={14}
style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
Symbol / Objekt einfuegen
</span>
<BarButton icon="close" onClick={onClose} title="Schliessen" />
</div>
{/* Toolbar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 14px',
borderBottom: '1px solid var(--border)',
}}>
<input type="text" value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Suchen (Name oder Tag)…"
autoFocus
style={{ flex: 1, height: BAR_H, padding: '0 12px',
fontSize: 11 }} />
<BarToggle label="Alle" active={typeFilter === 'all'}
onClick={() => setTypeFilter('all')} />
<BarToggle label="Symbole" active={typeFilter === 'symbol'}
onClick={() => setTypeFilter('symbol')} />
<BarToggle label="Objekte" active={typeFilter === 'object'}
onClick={() => setTypeFilter('object')} />
</div>
{/* Grid */}
<div style={{ flex: 1, minHeight: 200, overflowY: 'auto',
padding: 12 }}>
{filtered.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center',
color: 'var(--text-muted)' }}>
{placable.length === 0
? 'Keine Symbole/Objekte in der Library.'
: 'Nichts gefunden.'}
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
gap: 8,
}}>
{filtered.map(it => (
<ItemCard key={it.id} item={it} onPick={onPick} />
))}
</div>
)}
</div>
{/* Footer */}
<div style={{
padding: '8px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
fontSize: 10, color: 'var(--text-muted)',
}}>
Klick auf Item im Viewport Punkt waehlen zum Platzieren.
{filtered.length > 0 && (
<span> · {filtered.length} / {placable.length}</span>
)}
</div>
</div>
)
if (embedded) return content
return (
<div onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 200,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div onClick={(e) => e.stopPropagation()}>{content}</div>
</div>
)
}