Initial commit — Dossier Rhino 8 Plugin

OpenStudio-Suite Architektur-Plugin fuer Rhino 8 (Mac):
- Smart-Elemente: Wand, Decke, Dach (Pult/Sattel/Walm/Mansarde),
  Oeffnungen (Fenster/Tueren mit Rahmen + Sims + Glas + Fluegel),
  Treppen (gerade · L · Wendel mit Schrittmass-Validierung)
- Live-Previews mit Step-Lines + Soll-Range-Clamping
- Bidirektionale Selection-Sync zwischen Source-Linie und Volume
- Geschoss-/Ebenen-Verwaltung mit OKFF-Persistenz
- Layouts mit PDF-Export
- Ausschnitte / Massstab / Override-Regeln
- Petrol-Gruen Theme (Rapport-konform)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 04:27:41 +02:00
commit 9dc191be4f
145 changed files with 32629 additions and 0 deletions
+836
View File
@@ -0,0 +1,836 @@
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 (
<span title={landscape ? 'Querformat' : 'Hochformat'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, flexShrink: 0,
borderRadius: 999,
background: 'var(--bg-input)',
color: 'var(--accent)',
}}>
<Icon name={landscape ? 'crop_landscape' : 'crop_portrait'} size={14} />
</span>
)
}
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 (
<input
ref={inputRef}
value={text}
onChange={(e) => 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 (
<span
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true) }}
title={title || 'Doppelklick zum Umbenennen'}
style={{
flex: 1, cursor: 'text',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
...style,
}}
>
{value || '(unbenannt)'}
</span>
)
}
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: 'Loeschen', icon: 'delete', danger: true,
onClick: () => {
if (window.confirm(`Layout "${l.name}" loeschen?`)) 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 / abwaehlen',
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 (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)', color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 10px',
borderBottom: '1px solid var(--border)',
}}>
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em' }}>LAYOUTS</span>
<button
onClick={handleExportSelection}
className="btn-icon-tonal"
disabled={checked.size === 0}
title={checked.size > 0
? `Auswahl (${checked.size}) als ein PDF exportieren`
: 'Erst Layouts ankreuzen'}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<Icon name="picture_as_pdf" size={14} />
{checked.size > 0 && <span style={{ fontSize: 10 }}>({checked.size})</span>}
</button>
<button
onClick={() => exportPdfAll(300)}
className="btn-icon-tonal"
disabled={layouts.length === 0}
title="Alle Layouts als ein PDF exportieren"
>
<Icon name="picture_as_pdf" size={14} />
<span style={{ fontSize: 9, marginLeft: 2 }}>·</span>
</button>
<button
onClick={handleNewFolder}
className="btn-icon-tonal"
title="Neuer Ordner"
>
<Icon name="create_new_folder" size={14} />
</button>
<button
onClick={() => setDialog({ mode: 'new' })}
className="btn-add"
title="Neues Layout erstellen"
>
<Icon name="add" size={16} />
</button>
<button
onClick={() => listLayouts()}
className="btn-icon-tonal"
title="Aktualisieren"
>
<Icon name="refresh" size={14} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 8 }}>
{/* Layout-Liste */}
{layouts.length === 0 && folders.length === 0 ? (
<div style={{
padding: '32px 16px', textAlign: 'center',
color: 'var(--text-muted)', fontSize: 11,
border: '1px dashed var(--border)',
borderRadius: 'var(--r-lg)',
background: 'var(--bg-section)',
}}>
<Icon name="dashboard" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 8 }}>Noch keine Layouts.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
Oben <Icon name="add" size={11} /> klicken um ein neues Layout anzulegen.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
{/* 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 (
<div key={folderName}
onContextMenu={(ev) => {
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',
}}>
<div
onClick={() => toggleFolderCollapse(folderName)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
cursor: 'pointer', userSelect: 'none',
padding: 2,
}}
>
<input
type="checkbox"
checked={allChecked}
onClick={(e) => e.stopPropagation()}
onChange={() => checkAllInFolder(items)}
title="Alle in diesem Ordner ankreuzen"
style={{ margin: 0, flexShrink: 0 }}
/>
<span style={{
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
color: 'var(--text-muted)', display: 'inline-flex',
}}>
<Icon name="arrow_drop_down" size={16} />
</span>
<Icon name="folder" size={14} style={{ color: 'var(--warn)' }} />
<span style={{ flex: 1, fontSize: 11, fontWeight: 500,
color: 'var(--text-primary)' }}>
{folderName}
</span>
<span className="chip" style={{ fontSize: 8 }}>{items.length}</span>
<button
className="btn-icon-sm"
onClick={(ev) => {
ev.stopPropagation()
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName })
}}
title="Ordner-Aktionen"
>
<Icon name="more_vert" size={14} />
</button>
</div>
{!isCollapsed && (
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column' }}>
{items.map(l => (
<LayoutRow key={l.id} l={l}
active={l.id === selectedId}
checked={checked.has(l.id)}
dragging={draggingId === l.id}
forceEditName={renamingId === l.id}
onEditDone={() => 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 && (
<div style={{
padding: '10px 6px', fontSize: 10, color: 'var(--text-muted)',
textAlign: 'center', fontStyle: 'italic',
}}>
Leer Layouts hier ablegen.
</div>
)}
</div>
)}
</div>
)
})}
{/* Root-Drop-Zone — auch wenn keine Root-Layouts existieren, damit
man ein Layout aus einem Ordner zurueck auf "kein Ordner"
ziehen kann. */}
<div
onDragOver={handleDragOver('')}
onDragLeave={handleDragLeave()}
onDrop={handleDrop('')}
style={{
display: 'flex', flexDirection: 'column', gap: 3,
padding: grouped.__root.length === 0 ? 8 : 0,
border: `1px ${dragTarget === '__root' ? 'solid var(--accent)' : 'dashed transparent'}`,
borderRadius: 'var(--r)',
background: dragTarget === '__root' ? 'var(--bg-item-active)' : 'transparent',
transition: 'all 0.1s',
minHeight: grouped.__root.length === 0 ? 32 : 0,
}}
>
{grouped.__root.map(l => (
<LayoutRow key={l.id} l={l}
active={l.id === selectedId}
checked={checked.has(l.id)}
dragging={draggingId === l.id}
forceEditName={renamingId === l.id}
onEditDone={() => 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 && (
<div style={{
textAlign: 'center', color: 'var(--text-muted)',
fontSize: 10, fontStyle: 'italic',
}}>
Hier ablegen um aus Ordner zu entfernen
</div>
)}
</div>
</div>
)}
{/* Details des aktuell gewaehlten Layouts */}
{selected && (
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
<span style={{ ...labelXs, flex: 1 }}>
Details · {selected.name}
</span>
<button
onClick={() => addDetail(selected.id, null)}
className="btn-icon-tonal"
title="Neues Detail (zentriert auf Seite)"
style={{ marginRight: 4 }}
>
<Icon name="add" size={13} />
</button>
<button
onClick={() => syncLayout(selected.id)}
className="btn-icon-tonal"
disabled={details.length === 0}
title="Alle Details mit ihren Ausschnitten neu synchronisieren"
>
<Icon name="sync" size={13} />
</button>
</div>
{details.length === 0 ? (
<div style={{
padding: '20px 12px', textAlign: 'center',
color: 'var(--text-muted)', fontSize: 11,
border: '1px dashed var(--border)',
borderRadius: 'var(--r)',
background: 'var(--bg-input)',
}}>
<Icon name="crop_landscape" size={24} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 6 }}>Keine Details auf diesem Layout.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
Oben <Icon name="add" size={11} /> klicken um eines hinzuzufuegen.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{details.map((d, i) => (
<div key={d.id} style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px',
background: 'var(--bg-input)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--r)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="chip" style={{ flexShrink: 0 }}>#{i + 1}</span>
<span style={{
flex: 1, fontWeight: 500,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{d.name || '(Detail)'}
</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)', fontFamily: 'DM Mono, monospace' }}>
{Math.round(d.width)}×{Math.round(d.height)}
</span>
<button
className="btn-icon-sm btn-icon-danger"
onClick={() => {
if (window.confirm('Detail loeschen?')) deleteDetail(selected.id, d.id)
}}
title="Detail loeschen"
>
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<select
value={d.boundAusschnitt || ''}
onChange={(e) => bindAusschnitt(selected.id, d.id, e.target.value || null)}
style={{ flex: 1, fontSize: 11 }}
title="Welcher Ausschnitt auf diesem Detail liegt"
>
<option value=""> kein Ausschnitt </option>
{snaps.map(s => (
<option key={s.id} value={s.id}>
{s.name}{s.folder ? ` · ${s.folder}` : ''}{s.scale ? ` · ${s.scale}` : ''}
</option>
))}
</select>
<button
onClick={() => syncDetail(selected.id, d.id)}
disabled={!d.boundAusschnitt}
className="btn-icon-sm"
title="Gebundenen Ausschnitt neu anwenden"
>
<Icon name="sync" size={12} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Kontextmenue */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={
ctxMenu.kind === 'folder'
? folderCtxItems(ctxMenu.id)
: (() => {
const l = layouts.find(x => x.id === ctxMenu.id)
return l ? layoutCtxItems(l) : []
})()
}
onClose={() => setCtxMenu(null)}
/>
)}
{/* Layout-Dialog: New oder Edit (Papierformat aendern) */}
{dialog && (
<LayoutDialog
mode={dialog.mode}
layout={dialog.layout}
onCancel={() => 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)
}}
/>
)}
</div>
)
}
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 (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}} onClick={(e) => { if (e.target === e.currentTarget) onCancel() }}>
<div style={{
width: 340, background: 'var(--bg-base)',
border: '1px solid var(--border)', borderRadius: 'var(--r-lg)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)',
fontWeight: 600 }}>
{editing ? `Papierformat: ${layout?.name}` : 'Neues Layout'}
</div>
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{!editing && (
<div>
<div style={labelXs}>Name</div>
<input type="text" value={name} onChange={(e) => setName(e.target.value)}
placeholder="z.B. Grundriss EG"
autoFocus
style={{ width: '100%', marginTop: 4 }} />
</div>
)}
<div>
<div style={labelXs}>Papierformat</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{PAPER_SIZES.map(f => (
<button key={f}
onClick={() => setFormat(f)}
className={format === f ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11 }}>
{f}
</button>
))}
<button
onClick={() => setFormat('custom')}
className={format === 'custom' ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11 }}>
Eigene
</button>
</div>
</div>
{format === 'custom' ? (
<div>
<div style={labelXs}>Eigene Groesse (mm)</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4, alignItems: 'center' }}>
<input type="text" value={cw} onChange={(e) => setCw(e.target.value)}
placeholder="Breite"
style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>×</span>
<input type="text" value={ch} onChange={(e) => setCh(e.target.value)}
placeholder="Hoehe"
style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>mm</span>
</div>
</div>
) : (
<div>
<div style={labelXs}>Ausrichtung</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<button
onClick={() => setLandscape(true)}
className={landscape ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
<Icon name="crop_landscape" size={12} /> Quer
</button>
<button
onClick={() => setLandscape(false)}
className={!landscape ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
<Icon name="crop_portrait" size={12} /> Hoch
</button>
</div>
</div>
)}
</div>
<div style={{ padding: 10, borderTop: '1px solid var(--border)',
display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
<button onClick={onCancel}>Abbrechen</button>
<button className="btn-contained"
onClick={() => {
const payload = { name: name.trim(), format, landscape }
if (format === 'custom') {
const w = parseFloat(cw), h = parseFloat(ch)
if (!(w > 0) || !(h > 0)) { alert('Bitte gueltige Groesse eingeben.'); return }
payload.customWidth = w
payload.customHeight = h
}
onSubmit(payload)
}}>
{editing ? 'Anwenden' : 'Erstellen'}
</button>
</div>
</div>
</div>
)
}
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 (
<div
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onSelect}
onDoubleClick={onActivate}
onContextMenu={onContextMenu}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 8px',
border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border-light)'),
borderRadius: 'var(--r)',
background: active ? 'var(--bg-item-active)' : 'var(--bg-input)',
cursor: 'grab',
userSelect: 'none',
marginBottom: 4,
opacity: dragging ? 0.4 : 1,
transition: 'background 0.14s, border-color 0.14s, opacity 0.14s',
}}
onMouseEnter={(ev) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-item-hover)' }}
onMouseLeave={(ev) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-input)' }}
>
<input
type="checkbox"
checked={checked}
onClick={(e) => e.stopPropagation()}
onChange={onCheck}
style={{ margin: 0, flexShrink: 0 }}
title="Fuer PDF-Export ankreuzen"
/>
<OrientationBadge landscape={landscape} />
<EditableName
value={l.name}
onCommit={onRename}
forceEdit={forceEditName}
onEditDone={onEditDone}
style={{
flex: 1, minWidth: 0,
color: 'var(--text-primary)', fontWeight: 500,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
/>
<span className="chip chip-accent" style={{
fontSize: 9, fontFamily: 'DM Mono, monospace', fontWeight: 600,
flexShrink: 0,
}} title={`${Math.round(l.widthMm || 0)}×${Math.round(l.heightMm || 0)} mm`}>
{formatLabel(l.widthMm, l.heightMm)}
</span>
<button
className="btn-icon-sm"
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
title="Aktionen"
>
<Icon name="more_vert" size={14} />
</button>
</div>
)
}