Raumstempel: Drag-Drop-Layout + persistente Position + Oberleiste-Sync

Datenmodell auf der raum_outline:
- dossier_raum_stamp_dx/dy: Stempel-Offset zum Outline-Centroid
- dossier_raum_layout: JSON-Array of-Rows fuer Multi-Field-pro-Zeile
- dossier_raum_txt_font/bold/italic + raum_show_*: Typografie-Overrides
- Legacy show_*-Flags bleiben Fallback wenn kein Layout gesetzt

Backend:
- _make_raum_stamp_text: Layout-Renderer (Rows zu Lines), Offset wird in
  _regenerate_element_body auf den Centroid addiert
- _sync_raum_stamps_to_source: laeuft am Anfang von _on_command_end,
  spiegelt aktuelle Stempel-Position + Font/Size/Style auf die Source
  zurueck → User-Edits via Move/Oberleiste/Properties ueberleben Regen
- _list_system_fonts: System-Fonts fuer Frontend-Dropdown
- Raumstempel-Bug-Fix: raum_outline jetzt in _on_command_end Regen-Pfad
  per Length-Check getriggert, snapshot um length erweitert. Vertex-Drag
  aktualisiert Flaechen-Wert. Outline-Sources fuegen affected_walls hinzu.
- raum_stamp aus _PAIRED_VOLUME_TYPES entfernt → einzeln greifbar/
  verschiebbar; Klick auf Outline pairt weiter alles drei.

Frontend (ElementeApp.jsx):
- StempelLayoutBuilder: HTML5 drag-and-drop UI, verfuegbare Felder als
  Pills oben (Klick = neue Row, Drag = in bestehende Row), bestehende
  Rows als drop-targets, × zum Entfernen
- Typografie-Block raus aus RaumProperties; Hinweis-Text auf Oberleiste
- PropertiesView nimmt jetzt fonts={state.fonts} (auch Satellite-Window)
This commit is contained in:
2026-05-26 20:42:32 +02:00
parent 02a00a9b4a
commit 01b6501a0c
3 changed files with 695 additions and 35 deletions
+227 -14
View File
@@ -506,7 +506,7 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
// PropertiesView: gemeinsame Komponente, rendert die passende Property-
// Form je nach Element-Typ. Wiederverwendbar in Inline + Satellite-Window.
export function PropertiesView({ selected, geschosse, materials, hatchPatterns, oeffStyles }) {
export function PropertiesView({ selected, geschosse, materials, hatchPatterns, oeffStyles, fonts }) {
if (!selected) return null
const upd = (p) => updateElement(selected.id, p)
const del = (label) => () => { if (window.confirm(`${label} löschen?`)) deleteElement(selected.id) }
@@ -528,7 +528,9 @@ export function PropertiesView({ selected, geschosse, materials, hatchPatterns,
}
if (selected.kind === 'raum')
return <RaumProperties raum={selected} geschosse={geschosse}
hatchPatterns={hatchPatterns} onUpdate={upd} onDelete={del('Raum')} />
hatchPatterns={hatchPatterns} fonts={fonts || []}
onUpdate={upd} onDelete={del('Raum')} />
if (selected.kind === 'aussparung')
return <AussparungProperties aussp={selected} onDelete={del('Aussparung')} />
// fenster/tuer
@@ -580,6 +582,7 @@ export default function ElementeApp() {
geschosse={geschosse}
materials={state.materials || []}
hatchPatterns={state.hatchPatterns}
fonts={state.fonts || []}
oeffStyles={state.oeffStyles || []} />
</div>
)}
@@ -711,19 +714,207 @@ function TragwerkProperties({ el, onUpdate, onDelete }) {
)
}
function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns }) {
// Field-Definitionen fuer den Stempel-Layout-Builder. Symmetrisch zu
// _RAUM_FIELD_IDS im Backend (elemente.py).
const RAUM_LAYOUT_FIELDS = [
{ id: 'nummer', label: 'Nummer', icon: 'tag' },
{ id: 'name', label: 'Name', icon: 'label' },
{ id: 'funktion', label: 'Funktion', icon: 'category' },
{ id: 'area', label: 'Fläche', icon: 'square_foot' },
{ id: 'sia', label: 'SIA-Tag', icon: 'class' },
]
// Layout-Builder mit Drag-and-Drop. Rows = Textzeilen, Felder in einer
// Row stehen nebeneinander. Drag-Quelle ist ein "active field" Pill oder
// ein "verfuegbares" Pill. Drop-Ziel ist eine Row (= an die Row anhaengen)
// oder die neue-Row-Drop-Zone unten.
function StempelLayoutBuilder({ layout, availableFields, onChange }) {
const [dragging, setDragging] = useState(null) // { id, fromRow|null }
const FIELD_META = Object.fromEntries(RAUM_LAYOUT_FIELDS.map(f => [f.id, f]))
const removeFromLayout = (fid) => {
const next = layout.map(row => row.filter(f => f !== fid))
.filter(row => row.length > 0)
return next
}
const handleDragStart = (e, fid, fromRow) => {
setDragging({ id: fid, fromRow })
try { e.dataTransfer.effectAllowed = 'move' } catch (_) {}
try { e.dataTransfer.setData('text/plain', fid) } catch (_) {}
}
const handleDragEnd = () => setDragging(null)
const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }
const handleDropOnRow = (e, rowIdx) => {
e.preventDefault()
if (!dragging) return
const next = removeFromLayout(dragging.id)
// Wenn die ge-droppte Source und das Ziel dieselbe Row ist → no-op
// (Within-Row-Reorder waere komplexer; ignorieren wir vorerst)
if (next[rowIdx]) next[rowIdx] = [...next[rowIdx], dragging.id]
else next.push([dragging.id])
onChange(next)
setDragging(null)
}
const handleDropOnNewRow = (e) => {
e.preventDefault()
if (!dragging) return
const next = removeFromLayout(dragging.id)
next.push([dragging.id])
onChange(next)
setDragging(null)
}
const handleRemove = (fid) => {
onChange(removeFromLayout(fid))
}
const handleAddFromAvailable = (fid) => {
onChange([...layout, [fid]])
}
const pillStyle = (isDragging) => ({
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 8px', fontSize: 10,
background: isDragging ? 'var(--accent)' : 'var(--bg-input)',
color: isDragging ? 'var(--bg-panel)' : 'var(--text-primary)',
border: '1px solid var(--border)', borderRadius: 999,
cursor: 'grab', userSelect: 'none',
fontFamily: 'DM Mono, monospace',
})
const rowStyle = {
display: 'flex', flexWrap: 'wrap', gap: 4,
padding: '6px 8px',
background: 'var(--bg-panel)',
border: '1px dashed var(--border)', borderRadius: 'var(--r)',
minHeight: 28, alignItems: 'center',
}
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 6,
paddingTop: 6, borderTop: '1px dashed var(--border)',
}}>
<span style={{ ...labelXs, marginBottom: 0 }}>Stempel-Layout</span>
<div style={{ fontSize: 9, color: 'var(--text-muted)', marginBottom: 2 }}>
Drag Felder zwischen Zeilen eine Zeile = eine Textzeile im Stempel.
</div>
{/* Verfuegbare Felder (Drag-Quelle) */}
{availableFields.length > 0 && (
<div style={{
display: 'flex', flexWrap: 'wrap', gap: 4,
padding: '4px 0', marginBottom: 2,
}}>
<span style={{ fontSize: 9, color: 'var(--text-muted)',
alignSelf: 'center', marginRight: 4 }}>+</span>
{availableFields.map(f => (
<span key={f.id}
draggable
onDragStart={(e) => handleDragStart(e, f.id, null)}
onDragEnd={handleDragEnd}
onClick={() => handleAddFromAvailable(f.id)}
title={`${f.label} hinzufügen (klick) oder in Zeile ziehen`}
style={{
...pillStyle(dragging && dragging.id === f.id),
opacity: 0.65, cursor: 'pointer',
}}>
<Icon name={f.icon} size={11} />{f.label}
</span>
))}
</div>
)}
{/* Rows */}
{layout.map((row, ri) => (
<div key={ri}
onDragOver={handleDragOver}
onDrop={(e) => handleDropOnRow(e, ri)}
style={rowStyle}>
{row.map(fid => {
const meta = FIELD_META[fid] || { label: fid, icon: 'label' }
return (
<span key={fid}
draggable
onDragStart={(e) => handleDragStart(e, fid, ri)}
onDragEnd={handleDragEnd}
style={pillStyle(dragging && dragging.id === fid)}>
<Icon name={meta.icon} size={11} />
{meta.label}
<button onClick={() => handleRemove(fid)}
title="Entfernen"
style={{
background: 'transparent', border: 'none',
cursor: 'pointer', padding: 0, marginLeft: 2,
color: 'inherit', opacity: 0.6,
}}>
<Icon name="close" size={10} />
</button>
</span>
)
})}
<span style={{ flex: 1, fontSize: 9, color: 'var(--text-muted)',
textAlign: 'right' }}>Zeile {ri + 1}</span>
</div>
))}
{/* Neue-Row Drop-Zone (nur wenn was dragable ist) */}
<div
onDragOver={handleDragOver}
onDrop={handleDropOnNewRow}
style={{
fontSize: 9, color: 'var(--text-muted)', textAlign: 'center',
padding: '6px 0',
border: '1px dashed transparent',
borderColor: dragging ? 'var(--accent)' : 'var(--border)',
borderRadius: 'var(--r)',
background: dragging ? 'var(--bg-item-hover)' : 'transparent',
transition: 'all 0.15s',
}}>
{dragging ? '↓ Hier ablegen für neue Zeile' : 'Drop hier für neue Zeile'}
</div>
</div>
)
}
function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns, fonts }) {
const [name, setName] = useState(raum.name || 'Raum')
const [nummer, setNummer] = useState(raum.nummer || '')
const [txtH, setTxtH] = useState(String(raum.txtH || 0.20))
const [funktion, setFunktion] = useState(raum.funktion || '')
useEffect(() => {
setName(raum.name || 'Raum')
setNummer(raum.nummer || '')
setTxtH(String(raum.txtH || 0.20))
}, [raum.id, raum.name, raum.nummer, raum.txtH])
setFunktion(raum.funktion || '')
}, [raum.id, raum.name, raum.nummer, raum.funktion])
// Aktueller Wert von raum_fuellung: "" | "Solid" | "Hatch1" | … | "ByLayer"
const fuell = raum.fuellung || ''
const patternList = hatchPatterns || []
// Layout aus State, mit Fallback auf show_*-Flags (backwards-compat
// fuer Raeume ohne explizites Layout). Backend faellt ebenfalls auf
// dieselbe Konvention zurueck.
const rawLayout = Array.isArray(raum.layout) ? raum.layout : []
const layout = rawLayout.length > 0 ? rawLayout : (() => {
const head = []
if (raum.showNummer !== false) head.push('nummer')
if (raum.showName !== false) head.push('name')
const rows = []
if (head.length) rows.push(head)
if (raum.showFunktion !== false) rows.push(['funktion'])
const tail = []
if (raum.showArea !== false) tail.push('area')
if (raum.showSia) tail.push('sia')
if (tail.length) rows.push(tail)
return rows.length ? rows : [['name']]
})()
const usedFields = new Set(layout.flat())
const availableFields = RAUM_LAYOUT_FIELDS.filter(f => !usedFields.has(f.id))
return (
<div style={{
@@ -837,17 +1028,39 @@ function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns })
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Texthöhe</span>
<input type="text" value={txtH}
onChange={(e) => setTxtH(e.target.value)}
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Funktion</span>
<input type="text" value={funktion}
onChange={(e) => setFunktion(e.target.value)}
onBlur={() => {
const v = parseFloat(txtH)
if (v > 0 && v !== raum.txtH) onUpdate({ txtH: v })
else setTxtH(String(raum.txtH))
const v = (funktion || '').trim()
if (v !== (raum.funktion || '')) onUpdate({ funktion: v })
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11,
fontFamily: 'DM Mono, monospace' }} />
placeholder="z.B. Wohnen, Bad, Büro …"
style={{ flex: 1, fontSize: 11 }} />
</div>
{/* Stempel-Layout — Drag-and-Drop. Jede Row ist eine Textzeile,
Felder innerhalb einer Row landen in derselben Zeile. Drag
zwischen Rows um umzuordnen. Klick auf Field oben fügt es in
eine eigene neue Row hinzu. */}
<StempelLayoutBuilder
layout={layout}
availableFields={availableFields}
onChange={(newLayout) => onUpdate({ layout: newLayout })} />
{/* Hinweis: Typografie (Font/Stil/Höhe) wird in der OBERLEISTE
gesetzt — den Stempel im Viewport anklicken. Aenderungen werden
automatisch auf die Raum-Outline gespiegelt damit sie beim
naechsten Regen erhalten bleiben. */}
<div style={{
fontSize: 9, color: 'var(--text-muted)',
paddingTop: 4, borderTop: '1px dashed var(--border)',
display: 'flex', alignItems: 'center', gap: 4,
}}>
<Icon name="info" size={11} style={{ color: 'var(--text-muted)' }} />
Typografie (Font/Stil/Höhe): Stempel im Viewport selektieren
Oberleiste.
</div>
<div style={{