Symbol-Picker als Satellite-Fenster (statt Modal im Elemente-Panel)

UX-Verbesserung: Modal-Overlay im engen Elemente-Panel war unpraktisch.
Symbol-Picker oeffnet sich jetzt als eigenstaendiges Eto.Form-Fenster
(wie Library/Project-Settings).

Frontend:
- SymbolPicker bekommt embedded-Prop (Satellite-Mount vs Modal-Overlay)
- Neuer SymbolPickerApp Satellite-Wrapper (PANEL_PARAMS lesen + Bridge)
- main.jsx: 'symbol_picker' Mode-Routing
- ElementeApp: Symbol-Button ruft nur noch listLibrary() — Backend
  oeffnet das Fenster

Backend:
- _cmd_list_library oeffnet jetzt das Satellite-Window mit eigener
  Bridge (PICK -> CREATE_SYMBOL, CANCEL -> Close)
- PICK schliesst Fenster + triggert interactive GetPoint im Viewport

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 03:39:03 +02:00
parent 8184f559fc
commit de57c320c2
5 changed files with 168 additions and 115 deletions
+39 -7
View File
@@ -8092,18 +8092,50 @@ class ElementeBridge(panel_base.BaseBridge):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _cmd_list_library(self, p): def _cmd_list_library(self, p):
"""Liefert Library-Manifest ans Frontend (fuer den Symbol-Picker """Oeffnet den Symbol-Picker als Satellite-Window. Lieferung der
im Elemente-Panel). Antwort: LIBRARY_LIST message.""" Library-Items + Handling von PICK (User waehlt Item CREATE_SYMBOL
im aktiven Doc) und CANCEL (Fenster schliessen)."""
try: try:
import library import library
manifest = library.load_manifest() manifest = library.load_manifest()
self.send("LIBRARY_LIST", { items = manifest.get("items", [])
"items": manifest.get("items", []),
"name": manifest.get("name", "Dossier-Library"),
})
except Exception as ex: except Exception as ex:
print("[ELEMENTE] list library:", ex) print("[ELEMENTE] list library:", ex)
self.send("LIBRARY_LIST", {"items": [], "name": ""}) items = []
outer_bridge = self
bridge_holder = {"form": None}
class _SymbolPickerBridge(panel_base.BaseBridge):
def __init__(self):
panel_base.BaseBridge.__init__(self, "symbol_picker")
def handle(self, data):
if not isinstance(data, dict): return
t = data.get("type", "")
pp = data.get("payload") or {}
if t == "READY": pass
elif t == "PICK":
lib_id = (pp.get("id") or "").strip()
if lib_id:
# Fenster zu, dann interactive Place
try:
f = bridge_holder.get("form")
if f is not None: f.Close()
except Exception: pass
try:
outer_bridge._cmd_create_symbol({"id": lib_id})
except Exception as ex:
print("[SYMBOL-PICKER] CREATE_SYMBOL:", ex)
elif t == "CANCEL":
try:
f = bridge_holder.get("form")
if f is not None: f.Close()
except Exception: pass
b = _SymbolPickerBridge()
bridge_holder["form"] = panel_base.open_satellite_window(
"symbol_picker",
params={"items": items},
title="Symbol einfuegen",
size=(640, 560),
bridge=b)
def _cmd_create_symbol(self, p): def _cmd_create_symbol(self, p):
"""Platziert ein Library-Item (symbol/object) im Doc. Interactive """Platziert ein Library-Item (symbol/object) im Doc. Interactive
+3 -23
View File
@@ -9,9 +9,8 @@ import {
openSwisstopo, openSwisstopoDialog, openOsmDialog, openSwisstopo, openSwisstopoDialog, openOsmDialog,
updateElement, deleteElement, openElementeUebersicht, openElementeProperties, updateElement, deleteElement, openElementeUebersicht, openElementeProperties,
saveOeffStyle, deleteOeffStyle, saveOeffStyle, deleteOeffStyle,
listLibrary, createSymbol, listLibrary,
} from './lib/rhinoBridge' } from './lib/rhinoBridge'
import SymbolPicker from './components/SymbolPicker'
const labelXs = { const labelXs = {
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
@@ -329,16 +328,7 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
const [treppeMenuOpen, setTreppeMenuOpen] = useState(false) const [treppeMenuOpen, setTreppeMenuOpen] = useState(false)
const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false) const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false)
const [traegerMenuOpen, setTraegerMenuOpen] = useState(false) const [traegerMenuOpen, setTraegerMenuOpen] = useState(false)
const [symbolPickerOpen, setSymbolPickerOpen] = useState(false)
const [libraryItems, setLibraryItems] = useState([])
const treppeWrapperRef = useRef(null) const treppeWrapperRef = useRef(null)
// Library-Items kommen via LIBRARY_LIST message vom Backend nach LIST_LIBRARY.
useEffect(() => {
onMessage('LIBRARY_LIST', ({ items }) => {
if (Array.isArray(items)) setLibraryItems(items)
})
}, [])
const dis = noGeschoss const dis = noGeschoss
const baseHint = (label) => const baseHint = (label) =>
noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren' noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren'
@@ -491,9 +481,9 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
<PillGroup label="Library"> <PillGroup label="Library">
<PillButton icon="inventory_2" label="Symbol" <PillButton icon="inventory_2" label="Symbol"
hint={dis ? baseHint('Symbol') : hint={dis ? baseHint('Symbol') :
'Library-Item auswählen · im Viewport Punkt klicken zum Platzieren'} 'Library-Picker oeffnen · Item waehlen · im Viewport Punkt klicken zum Platzieren'}
disabled={dis} disabled={dis}
onClick={() => { listLibrary(); setSymbolPickerOpen(true) }} /> onClick={() => listLibrary()} />
</PillGroup> </PillGroup>
<PillGroup label="Importer"> <PillGroup label="Importer">
@@ -507,16 +497,6 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) {
hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion" hint="Öffnet map.geo.admin.ch im Browser zur visuellen Inspektion"
onClick={() => openSwisstopo('both')} /> onClick={() => openSwisstopo('both')} />
</PillGroup> </PillGroup>
{symbolPickerOpen && (
<SymbolPicker
items={libraryItems}
onPick={(id) => {
setSymbolPickerOpen(false)
createSymbol(id)
}}
onClose={() => setSymbolPickerOpen(false)} />
)}
</div> </div>
) )
} }
+25
View File
@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react'
import SymbolPicker from './components/SymbolPicker'
import { notifyReady, onMessage, send } from './lib/rhinoBridge'
export default function SymbolPickerApp() {
const initial = (typeof window !== 'undefined' && window.PANEL_PARAMS) || {}
const [items, setItems] = useState(initial.items || [])
useEffect(() => {
onMessage('LIBRARY_LIST', ({ items }) => {
if (Array.isArray(items)) setItems(items)
})
notifyReady()
const blockContext = (ev) => ev.preventDefault()
document.addEventListener('contextmenu', blockContext)
return () => document.removeEventListener('contextmenu', blockContext)
}, [])
return (
<SymbolPicker embedded
items={items}
onPick={(id) => send('PICK', { id })}
onClose={() => send('CANCEL', {})} />
)
}
+99 -85
View File
@@ -63,7 +63,7 @@ function ItemCard({ item, onPick }) {
) )
} }
export default function SymbolPicker({ items, onPick, onClose }) { export default function SymbolPicker({ items, onPick, onClose, embedded = false }) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('all') const [typeFilter, setTypeFilter] = useState('all')
@@ -84,94 +84,108 @@ export default function SymbolPicker({ items, onPick, onClose }) {
}) })
}, [placable, search, typeFilter]) }, [placable, search, typeFilter])
return ( const innerStyle = embedded ? {
<div style={{ width: '100%', height: '100%',
position: 'fixed', inset: 0, zIndex: 200, background: 'var(--bg-dialog)',
background: 'var(--bg-overlay)', display: 'flex', flexDirection: 'column',
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
}} onClick={onClose}> color: 'var(--text-primary)',
<div onClick={(e) => e.stopPropagation()} fontFamily: 'var(--font)', fontSize: 11,
style={{ } : {
width: '90%', maxWidth: 640, maxHeight: '80vh', width: '90%', maxWidth: 640, maxHeight: '80vh',
background: 'var(--bg-dialog)', background: 'var(--bg-dialog)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 12, borderRadius: 12,
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
color: 'var(--text-primary)', color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11, fontFamily: 'var(--font)', fontSize: 11,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)', boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
}}> }
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="inventory_2" size={14}
style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
Symbol / Objekt einfuegen
</span>
<BarButton icon="close" onClick={onClose} title="Schliessen" />
</div>
{/* Toolbar */} const content = (
<div style={{ <div style={innerStyle}>
display: 'flex', alignItems: 'center', gap: 6, {/* Header */}
padding: '8px 14px', <div style={{
borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8,
}}> padding: '10px 14px',
<input type="text" value={search} borderBottom: '1px solid var(--border)',
onChange={(ev) => setSearch(ev.target.value)} }}>
placeholder="Suchen (Name oder Tag)…" <Icon name="inventory_2" size={14}
autoFocus style={{ color: 'var(--accent)' }} />
style={{ flex: 1, height: BAR_H, padding: '0 12px', <span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
fontSize: 11 }} /> Symbol / Objekt einfuegen
<BarToggle label="Alle" active={typeFilter === 'all'} </span>
onClick={() => setTypeFilter('all')} /> <BarButton icon="close" onClick={onClose} title="Schliessen" />
<BarToggle label="Symbole" active={typeFilter === 'symbol'} </div>
onClick={() => setTypeFilter('symbol')} />
<BarToggle label="Objekte" active={typeFilter === 'object'}
onClick={() => setTypeFilter('object')} />
</div>
{/* Grid */} {/* Toolbar */}
<div style={{ flex: 1, minHeight: 200, overflowY: 'auto', <div style={{
padding: 12 }}> display: 'flex', alignItems: 'center', gap: 6,
{filtered.length === 0 ? ( padding: '8px 14px',
<div style={{ padding: 40, textAlign: 'center', borderBottom: '1px solid var(--border)',
color: 'var(--text-muted)' }}> }}>
{placable.length === 0 <input type="text" value={search}
? 'Keine Symbole/Objekte in der Library.' onChange={(ev) => setSearch(ev.target.value)}
: 'Nichts gefunden.'} placeholder="Suchen (Name oder Tag)…"
</div> autoFocus
) : ( style={{ flex: 1, height: BAR_H, padding: '0 12px',
<div style={{ fontSize: 11 }} />
display: 'grid', <BarToggle label="Alle" active={typeFilter === 'all'}
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', onClick={() => setTypeFilter('all')} />
gap: 8, <BarToggle label="Symbole" active={typeFilter === 'symbol'}
}}> onClick={() => setTypeFilter('symbol')} />
{filtered.map(it => ( <BarToggle label="Objekte" active={typeFilter === 'object'}
<ItemCard key={it.id} item={it} onPick={onPick} /> onClick={() => setTypeFilter('object')} />
))} </div>
</div>
)}
</div>
{/* Footer */} {/* Grid */}
<div style={{ <div style={{ flex: 1, minHeight: 200, overflowY: 'auto',
padding: '8px 14px', padding: 12 }}>
borderTop: '1px solid var(--border)', {filtered.length === 0 ? (
background: 'var(--bg-section)', <div style={{ padding: 40, textAlign: 'center',
fontSize: 10, color: 'var(--text-muted)', color: 'var(--text-muted)' }}>
}}> {placable.length === 0
Klick auf Item im Viewport Punkt waehlen zum Platzieren. ? 'Keine Symbole/Objekte in der Library.'
{filtered.length > 0 && ( : 'Nichts gefunden.'}
<span> · {filtered.length} / {placable.length}</span> </div>
)} ) : (
</div> <div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
gap: 8,
}}>
{filtered.map(it => (
<ItemCard key={it.id} item={it} onPick={onPick} />
))}
</div>
)}
</div>
{/* Footer */}
<div style={{
padding: '8px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
fontSize: 10, color: 'var(--text-muted)',
}}>
Klick auf Item im Viewport Punkt waehlen zum Platzieren.
{filtered.length > 0 && (
<span> · {filtered.length} / {placable.length}</span>
)}
</div> </div>
</div> </div>
) )
if (embedded) return content
return (
<div onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 200,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div onClick={(e) => e.stopPropagation()}>{content}</div>
</div>
)
} }
+2
View File
@@ -6,6 +6,7 @@ import ZeichnungsebenenApp from './ZeichnungsebenenApp.jsx'
import GeschossSettingsApp from './GeschossSettingsApp.jsx' import GeschossSettingsApp from './GeschossSettingsApp.jsx'
import ProjectSettingsApp from './ProjectSettingsApp.jsx' import ProjectSettingsApp from './ProjectSettingsApp.jsx'
import LibraryApp from './LibraryApp.jsx' import LibraryApp from './LibraryApp.jsx'
import SymbolPickerApp from './SymbolPickerApp.jsx'
import EbenenSettingsApp from './EbenenSettingsApp.jsx' import EbenenSettingsApp from './EbenenSettingsApp.jsx'
import GeschossDialogApp from './GeschossDialogApp.jsx' import GeschossDialogApp from './GeschossDialogApp.jsx'
import LayerCombinationsApp from './LayerCombinationsApp.jsx' import LayerCombinationsApp from './LayerCombinationsApp.jsx'
@@ -43,6 +44,7 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp
: mode === 'geschoss_settings' ? GeschossSettingsApp : mode === 'geschoss_settings' ? GeschossSettingsApp
: mode === 'project_settings' ? ProjectSettingsApp : mode === 'project_settings' ? ProjectSettingsApp
: mode === 'library' ? LibraryApp : mode === 'library' ? LibraryApp
: mode === 'symbol_picker' ? SymbolPickerApp
: mode === 'ebenen_settings' ? EbenenSettingsApp : mode === 'ebenen_settings' ? EbenenSettingsApp
: mode === 'geschoss_dialog' ? GeschossDialogApp : mode === 'geschoss_dialog' ? GeschossDialogApp
: mode === 'layer_combinations' ? LayerCombinationsApp : mode === 'layer_combinations' ? LayerCombinationsApp