961b3c0396
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>
837 lines
33 KiB
React
837 lines
33 KiB
React
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>
|
||
)
|
||
}
|