Files
DOSSIER/src/LayoutsApp.jsx
T
karim 961b3c0396 Snapshot: Wand/Öffnung Multi-Surface-Select + Z-Drag + Brüstungs-Mitnahme
Stable working state after a long iteration session. The plugin now supports:
- Multi-Surface-Select für alle Element-Typen (Türen/Fenster/Treppen/Tragwerk)
- Wand-Z-Drag → unbound mode (UK/OK-Override, Wand vom Geschoss entkoppelt)
- Wand-Z-Drag nimmt verknüpfte Öffnungen mit (Brüstung += delta_z via Idle-Pfad)
- Öffnungs-XY-Drag snapt direktional auf Wand-Tangente
- Öffnungs-Z-Drag passt Brüstung an (Fenster sofort sync, Tür deferred)
- Wand-Delete kaskadiert Öffnungen (deferred via Idle, robust gegen _Rotate/_Move)
- Source-Cascade beim Öffnungs-Delete (deferred analog Wand-Kaskade)
- Listener-Cleanup robust gegen _reset_panels.py Reload (Refs in
  _dossier_runtime_event_refs gespeichert, vor Re-Install deregistriert)
- _count_same_id_type filtert IsDeleted (verhindert Source-Duplikat-Bug bei Move)
- Frontend: Brüstungs-Slider für Tür ("Schwelle"), Flügel-Block nur bei Fenster

Plus aus früherer Phase dieser Session:
- Dossier-Launcher Auto-Load via Rhinos StartupCommands-XML
- Default-Pfad zeigt auf gebundeltes startup.py (out-of-the-box für neue User)
- Splash-Window beim Plugin-Load mit native macOS rounded corners
- Diverse Launcher-Verbesserungen (Brüstungs-Default, tauri.conf, capabilities)

Known issue: bei Multi-Select-Move mit vielen Sub-Volumen kann sporadisch
"Unable to transform" auftreten (Rhinos Move-Operation kollidiert mit Wand-
Regen). Tür-spezifischer Defer-Pfad mildert das, Fenster läuft sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:50:45 +02:00

837 lines
33 KiB
React
Raw 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.
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: '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 (
<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 hinzuzufügen.
</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="Höhe"
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>
)
}