import { useEffect, useState, useRef } from 'react'
import Icon from './components/Icon'
import ContextMenu from './components/ContextMenu'
import {
onMessage, notifyReady,
listLayouts, deleteLayout, renameLayout, activateLayout,
addDetail, deleteDetail, bindAusschnitt, syncDetail, syncLayout,
exportPdf, exportPdfAll, exportPdfMany,
addLayoutFolder, removeLayoutFolder, setLayoutFolder,
openLayoutDialog,
} from './lib/rhinoBridge'
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 [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 ändern', icon: 'aspect_ratio',
onClick: () => openLayoutDialog('edit', {
id: l.id, name: l.name, width: l.widthMm, height: l.heightMm,
}) },
{ 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 (
{/* Layout-Liste */}
{layouts.length === 0 && folders.length === 0 ? (
Noch keine Layouts.
Unten 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)}
))}
)}
)}
{/* Sticky Footer: Anzahl + Aktionen */}
{layouts.length}
Layouts
{/* PDF-Aktionen: feste Breite damit das Auswahl-Counter den Footer
nicht horizontal verschiebt. */}
{/* Kontextmenue */}
{ctxMenu && (
{
const l = layouts.find(x => x.id === ctxMenu.id)
return l ? layoutCtxItems(l) : []
})()
}
onClose={() => setCtxMenu(null)}
/>
)}
)
}
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)}
)
}