import { useEffect, useState, useRef } from 'react' import Icon from './components/Icon' import ContextMenu from './components/ContextMenu' import { onMessage, notifyReady, listLayouts, newLayout, deleteLayout, renameLayout, activateLayout, addDetail, deleteDetail, bindAusschnitt, syncDetail, syncLayout, setPageSize, exportPdf, exportPdfAll, exportPdfMany, addLayoutFolder, removeLayoutFolder, setLayoutFolder, } from './lib/rhinoBridge' const PAPER_SIZES = ['A4', 'A3', 'A2', 'A1', 'A0', 'Letter'] const PAPER_FORMATS_MM = { A0: [841, 1189], A1: [594, 841], A2: [420, 594], A3: [297, 420], A4: [210, 297], Letter: [216, 279], } function detectFormat(wMm, hMm, tol = 2) { if (!(wMm > 0) || !(hMm > 0)) return null const a = Math.min(wMm, hMm), b = Math.max(wMm, hMm) for (const [name, [sw, sh]] of Object.entries(PAPER_FORMATS_MM)) { if (Math.abs(a - sw) <= tol && Math.abs(b - sh) <= tol) return name } return null } function formatLabel(wMm, hMm) { if (!(wMm > 0) || !(hMm > 0)) return '—' const f = detectFormat(wMm, hMm) if (!f) return `${Math.round(wMm)}×${Math.round(hMm)}mm` const landscape = wMm > hMm return landscape ? `${f} ↔` : `${f} ↕` } function OrientationBadge({ landscape }) { return ( ) } const cardStyle = { background: 'var(--bg-section)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 8, marginBottom: 6, } const labelXs = { fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase', } // Inline-editierbarer Name — Doppelklick aktiviert, Enter/Blur committet. // forceEdit triggert Edit-Mode von aussen (z.B. Kontextmenue „Umbenennen"). function EditableName({ value, onCommit, style, title, forceEdit, onEditDone }) { const [editing, setEditing] = useState(false) const [text, setText] = useState(value || '') const inputRef = useRef(null) useEffect(() => { if (!editing) setText(value || '') }, [value, editing]) useEffect(() => { if (editing) { inputRef.current?.focus(); inputRef.current?.select() } }, [editing]) useEffect(() => { if (forceEdit) setEditing(true) }, [forceEdit]) if (editing) { return ( setText(e.target.value)} onBlur={() => { setEditing(false) const t = text.trim() if (t && t !== value) onCommit(t) else setText(value || '') onEditDone && onEditDone() }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() else if (e.key === 'Escape') { setText(value || ''); setEditing(false); onEditDone && onEditDone() } }} onClick={(e) => e.stopPropagation()} style={{ flex: 1, fontSize: 'inherit', fontFamily: 'inherit', ...style }} /> ) } return ( { e.stopPropagation(); setEditing(true) }} title={title || 'Doppelklick zum Umbenennen'} style={{ flex: 1, cursor: 'text', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...style, }} > {value || '(unbenannt)'} ) } export default function LayoutsApp() { const [state, setState] = useState({ layouts: [], snapshots: [], details: {}, folders: [] }) const [selectedId, setSelectedId] = useState(null) const [dialog, setDialog] = useState(null) // { mode: 'new' | 'edit', layout? } const [checked, setChecked] = useState(new Set()) // Multi-Select IDs const [collapsedFolders, setCollapsedFolders] = useState(new Set()) const [draggingId, setDraggingId] = useState(null) const [dragTarget, setDragTarget] = useState(null) // '__root' | folderName const [ctxMenu, setCtxMenu] = useState(null) // {x,y,kind,id} const [renamingId, setRenamingId] = useState(null) // signal to inline-edit useEffect(() => { onMessage('STATE', (s) => setState((prev) => ({ ...prev, ...s }))) notifyReady() }, []) const layouts = state.layouts || [] const snaps = state.snapshots || [] const folders = state.folders || [] const details = (selectedId && state.details && state.details[selectedId]) || [] const selected = layouts.find(l => l.id === selectedId) // Layouts gruppieren: Root + per Folder const grouped = { __root: [] } for (const f of folders) grouped[f] = [] for (const l of layouts) { const f = l.folder || '__root' if (!grouped[f]) grouped[f] = [] grouped[f].push(l) } const toggleCheck = (id) => { setChecked(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) } const toggleFolderCollapse = (name) => { setCollapsedFolders(prev => { const next = new Set(prev) if (next.has(name)) next.delete(name); else next.add(name) return next }) } const checkAllInFolder = (folderLayouts) => { setChecked(prev => { const next = new Set(prev) const allIn = folderLayouts.every(l => next.has(l.id)) if (allIn) folderLayouts.forEach(l => next.delete(l.id)) else folderLayouts.forEach(l => next.add(l.id)) return next }) } const handleExportSelection = () => { const ids = [...checked].filter(id => layouts.find(l => l.id === id)) if (ids.length === 0) return exportPdfMany(ids, 300) } const handleExportFolder = (folderName) => { const ids = (grouped[folderName] || []).map(l => l.id) if (ids.length === 0) return exportPdfMany(ids, 300) } const handleNewFolder = () => { const name = window.prompt('Name fuer neuen Ordner:') if (name && name.trim()) addLayoutFolder(name.trim()) } // Kontextmenue-Items pro Layout const layoutCtxItems = (l) => [ { label: 'In Rhino oeffnen', icon: 'open_in_new', onClick: () => activateLayout(l.id) }, { label: 'Umbenennen', icon: 'edit', onClick: () => setRenamingId(l.id) }, { divider: true }, { label: 'Als PDF exportieren', icon: 'picture_as_pdf', onClick: () => exportPdf(l.id, 300) }, { label: 'Papierformat aendern', icon: 'aspect_ratio', onClick: () => setDialog({ mode: 'edit', layout: l }) }, { divider: true }, ...(folders.length > 0 ? [ ...folders.map(f => ({ label: `Verschieben → ${f}`, icon: 'folder', disabled: l.folder === f, onClick: () => setLayoutFolder(l.id, f), })), { label: 'Aus Ordner entfernen', icon: 'folder_off', disabled: !l.folder, onClick: () => setLayoutFolder(l.id, '') }, { divider: true }, ] : []), { label: 'Löschen', icon: 'delete', danger: true, onClick: () => { if (window.confirm(`Layout "${l.name}" löschen?`)) deleteLayout(l.id) } }, ] // Kontextmenue-Items pro Ordner const folderCtxItems = (folderName) => { const items = grouped[folderName] || [] return [ { label: collapsedFolders.has(folderName) ? 'Aufklappen' : 'Einklappen', icon: collapsedFolders.has(folderName) ? 'expand_more' : 'expand_less', onClick: () => toggleFolderCollapse(folderName) }, { divider: true }, { label: 'Alle ankreuzen / abwählen', icon: 'check_box', onClick: () => checkAllInFolder(items) }, { label: `Ordner als PDF (${items.length})`, icon: 'picture_as_pdf', disabled: items.length === 0, onClick: () => handleExportFolder(folderName) }, { divider: true }, { label: 'Ordner umbenennen', icon: 'edit', onClick: () => { const next = window.prompt('Neuer Ordner-Name:', folderName) if (next && next.trim() && next !== folderName) { // Atomar via Server-Side waere besser; simpler: alle Layouts // umhaengen + alten loeschen + neuen anlegen. addLayoutFolder(next.trim()) items.forEach(l => setLayoutFolder(l.id, next.trim())) removeLayoutFolder(folderName) } } }, { label: 'Ordner loeschen', icon: 'folder_off', danger: true, onClick: () => { if (window.confirm(`Ordner "${folderName}" loeschen? Layouts werden zur Wurzel verschoben.`)) removeLayoutFolder(folderName) } }, ] } // Drag&Drop-Handler — analog Ausschnitte-Pattern const handleDragOver = (folderName) => (ev) => { ev.preventDefault() ev.dataTransfer.dropEffect = 'move' setDragTarget(folderName || '__root') } const handleDragLeave = () => () => setDragTarget(null) const handleDrop = (folderName) => (ev) => { ev.preventDefault() setDragTarget(null) const id = ev.dataTransfer.getData('text/plain') || draggingId if (id) setLayoutFolder(id, folderName || '') setDraggingId(null) } // Auto-Select erstes Layout wenn nichts gewaehlt useEffect(() => { if (selectedId == null && layouts.length > 0) setSelectedId(layouts[0].id) if (selectedId != null && !layouts.find(l => l.id === selectedId)) { setSelectedId(layouts[0]?.id || null) } }, [layouts, selectedId]) return (
{/* Header */}
LAYOUTS
{/* Layout-Liste */} {layouts.length === 0 && folders.length === 0 ? (
Noch keine Layouts.
Oben klicken um ein neues Layout anzulegen.
) : (
{/* Ordner-Gruppen */} {folders.map(folderName => { const items = grouped[folderName] || [] const isCollapsed = collapsedFolders.has(folderName) const allChecked = items.length > 0 && items.every(l => checked.has(l.id)) const isDropTarget = dragTarget === folderName return (
{ ev.preventDefault(); ev.stopPropagation() setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName }) }} onDragOver={handleDragOver(folderName)} onDragLeave={handleDragLeave()} onDrop={handleDrop(folderName)} style={{ border: `1px solid ${isDropTarget ? 'var(--accent-border)' : 'var(--border)'}`, borderRadius: 'var(--r-lg)', background: isDropTarget ? 'var(--accent-dim)' : 'var(--bg-section)', marginBottom: 8, padding: 8, transition: 'background 0.14s, border-color 0.14s', }}>
toggleFolderCollapse(folderName)} style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', userSelect: 'none', padding: 2, }} > e.stopPropagation()} onChange={() => checkAllInFolder(items)} title="Alle in diesem Ordner ankreuzen" style={{ margin: 0, flexShrink: 0 }} /> {folderName} {items.length}
{!isCollapsed && (
{items.map(l => ( setRenamingId(null)} onCheck={() => toggleCheck(l.id)} onSelect={() => setSelectedId(l.id)} onActivate={() => activateLayout(l.id)} onRename={(n) => renameLayout(l.id, n)} onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation() setSelectedId(l.id) setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id }) }} onMenuClick={(ev) => { setSelectedId(l.id) setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id }) }} onDragStart={(ev) => { ev.dataTransfer.setData('text/plain', l.id) ev.dataTransfer.effectAllowed = 'move' setDraggingId(l.id) }} onDragEnd={() => { setDraggingId(null); setDragTarget(null) }} /> ))} {items.length === 0 && (
Leer — Layouts hier ablegen.
)}
)}
) })} {/* Root-Drop-Zone — auch wenn keine Root-Layouts existieren, damit man ein Layout aus einem Ordner zurueck auf "kein Ordner" ziehen kann. */}
{grouped.__root.map(l => ( setRenamingId(null)} onCheck={() => toggleCheck(l.id)} onSelect={() => setSelectedId(l.id)} onActivate={() => activateLayout(l.id)} onRename={(n) => renameLayout(l.id, n)} onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation() setSelectedId(l.id) setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id }) }} onMenuClick={(ev) => { setSelectedId(l.id) setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id }) }} onDragStart={(ev) => { ev.dataTransfer.setData('text/plain', l.id) ev.dataTransfer.effectAllowed = 'move' setDraggingId(l.id) }} onDragEnd={() => { setDraggingId(null); setDragTarget(null) }} /> ))} {grouped.__root.length === 0 && draggingId && (
Hier ablegen um aus Ordner zu entfernen
)}
)} {/* Details des aktuell gewaehlten Layouts */} {selected && (
Details · {selected.name}
{details.length === 0 ? (
Keine Details auf diesem Layout.
Oben klicken um eines hinzuzufügen.
) : (
{details.map((d, i) => (
#{i + 1} {d.name || '(Detail)'} {Math.round(d.width)}×{Math.round(d.height)}
))}
)}
)}
{/* Kontextmenue */} {ctxMenu && ( { const l = layouts.find(x => x.id === ctxMenu.id) return l ? layoutCtxItems(l) : [] })() } onClose={() => setCtxMenu(null)} /> )} {/* Layout-Dialog: New oder Edit (Papierformat aendern) */} {dialog && ( setDialog(null)} onSubmit={(p) => { if (dialog.mode === 'new') { newLayout(p.name, p.format, p.landscape, p.customWidth, p.customHeight) } else { setPageSize(dialog.layout.id, p.format, p.landscape, p.customWidth, p.customHeight) } setDialog(null) }} /> )}
) } function LayoutDialog({ mode, layout, onCancel, onSubmit }) { const editing = mode === 'edit' const [name, setName] = useState('') const [format, setFormat] = useState('A3') const [landscape, setLandscape] = useState(true) const [cw, setCw] = useState('420') // mm const [ch, setCh] = useState('297') // mm // Wenn editieren: aktuelle Layout-Groesse pre-fillen (custom-Mode default) useEffect(() => { if (editing && layout) { // BBox in Doc-Einheiten — wir kennen die Einheit nicht direkt im // Frontend. Fuer den Edit-Modus zeigen wir die Groesse als Zahlen an // und schicken sie als "custom" mit der mm-Annahme. Wenn das Doc nicht // auf mm steht, ergibt sich eine kleine Konvertier-Unschaerfe — das // Backend rechnet mm-Werte konsistent in Doc-Units um. setFormat('custom') setCw(String(Math.round(layout.width))) setCh(String(Math.round(layout.height))) } }, [editing, layout]) return (
{ if (e.target === e.currentTarget) onCancel() }}>
{editing ? `Papierformat: ${layout?.name}` : 'Neues Layout'}
{!editing && (
Name
setName(e.target.value)} placeholder="z.B. Grundriss EG" autoFocus style={{ width: '100%', marginTop: 4 }} />
)}
Papierformat
{PAPER_SIZES.map(f => ( ))}
{format === 'custom' ? (
Eigene Groesse (mm)
setCw(e.target.value)} placeholder="Breite" style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} /> × setCh(e.target.value)} placeholder="Höhe" style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} /> mm
) : (
Ausrichtung
)}
) } function LayoutRow({ l, active, checked, dragging, forceEditName, onCheck, onSelect, onActivate, onRename, onContextMenu, onMenuClick, onDragStart, onDragEnd, onEditDone }) { const landscape = (l.widthMm || l.width) >= (l.heightMm || l.height) return (
{ if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-item-hover)' }} onMouseLeave={(ev) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-input)' }} > e.stopPropagation()} onChange={onCheck} style={{ margin: 0, flexShrink: 0 }} title="Fuer PDF-Export ankreuzen" /> {formatLabel(l.widthMm, l.heightMm)}
) }