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
0
? `Auswahl (${checked.size}) als ein PDF exportieren`
: 'Erst Layouts ankreuzen'}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
{checked.size > 0 && ({checked.size}) }
exportPdfAll(300)}
className="btn-icon-tonal"
disabled={layouts.length === 0}
title="Alle Layouts als ein PDF exportieren"
>
·∗
setDialog({ mode: 'new' })}
className="btn-add"
title="Neues Layout erstellen"
>
listLayouts()}
className="btn-icon-tonal"
title="Aktualisieren"
>
{/* 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}
{
ev.stopPropagation()
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName })
}}
title="Ordner-Aktionen"
>
{!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}
addDetail(selected.id, null)}
className="btn-icon-tonal"
title="Neues Detail (zentriert auf Seite)"
style={{ marginRight: 4 }}
>
syncLayout(selected.id)}
className="btn-icon-tonal"
disabled={details.length === 0}
title="Alle Details mit ihren Ausschnitten neu synchronisieren"
>
{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)}
{
if (window.confirm('Detail loeschen?')) deleteDetail(selected.id, d.id)
}}
title="Detail loeschen"
>
bindAusschnitt(selected.id, d.id, e.target.value || null)}
style={{ flex: 1, fontSize: 11 }}
title="Welcher Ausschnitt auf diesem Detail liegt"
>
— kein Ausschnitt —
{snaps.map(s => (
{s.name}{s.folder ? ` · ${s.folder}` : ''}{s.scale ? ` · ${s.scale}` : ''}
))}
syncDetail(selected.id, d.id)}
disabled={!d.boundAusschnitt}
className="btn-icon-sm"
title="Gebundenen Ausschnitt neu anwenden"
>
))}
)}
)}
{/* 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 && (
)}
Papierformat
{PAPER_SIZES.map(f => (
setFormat(f)}
className={format === f ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11 }}>
{f}
))}
setFormat('custom')}
className={format === 'custom' ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11 }}>
Eigene
{format === 'custom' ? (
) : (
Ausrichtung
setLandscape(true)}
className={landscape ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
Quer
setLandscape(false)}
className={!landscape ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
Hoch
)}
Abbrechen
{
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'}
)
}
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)}
{ ev.stopPropagation(); onMenuClick(ev) }}
title="Aktionen"
>
)
}