95031ee2c0
- Ebenenkombination raus aus Ebenen-Panel, in Oberleiste-Topbar + Editor-Satellite (AusschnittLayerDialog embedded). doc.Strings haelt active_comb_name, auto-clear bei manueller Eye/Lock-Aenderung. - EbenenSettingsDialog jetzt Satellite mit Ebene-Picker-Dropdown (auto-save on switch via SAVE_KEEP). - Per-Ausschnitt Einstellungen-Satellite (Massstab, Display, Overrides, Ebenenkombi). Alte 'Sichtbarkeit bearbeiten'-Option entfernt. - Layouts/Ausschnitte: Top-Header weg, Sticky-Footer mit Anzahl + Aktionen. LayoutDialog ist jetzt Satellite mit Format-Live-Preview. - Panel-Captions + Default-Ebenen-Namen auf Mixed-Case (Ausschnitte, Ebenen, Waende ...). Nur DOSSIER bleibt caps. - DimensionenApp: Card-Optik raus, REF-Wuerfel mit Kreisen statt Quadraten + Hover-Scale. - GeschossManager angeglichen an EbenenManager: Rechtsklick-Menue, Lock-Button, Delete-X, Duplizieren. layer_builder honoriert z.locked. - Active Sublayer folgt jetzt dem Geschoss-Wechsel (gleicher Code unter neuem Parent). Performance Geschoss-Wechsel: - elemente._send_state() ersetzt durch _notify_active_geschoss() (Partial-Push statt 200+ Elements re-enumerieren). - _apply_visibility dedupe via sticky last-applied-signature (STATE_SYNC-Echo loopt nicht mehr durch alle Layer). - _update_clipping nur wenn alt oder neu hasClipping=True. - Redundante doc.Views.Redraw() im CPlane-Pfad entfernt — die folgende apply_visibility-Roundtrip redrawt 30ms spaeter ohnehin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
483 lines
17 KiB
React
483 lines
17 KiB
React
import { useState, useEffect, useMemo } from 'react'
|
|
import Icon from './components/Icon'
|
|
import ContextMenu from './components/ContextMenu'
|
|
import {
|
|
onMessage, notifyReady,
|
|
listAusschnitte, saveAusschnitt, updateAusschnitt,
|
|
restoreAusschnitt, applyAusschnittToDetail,
|
|
renameAusschnitt, deleteAusschnitt,
|
|
setAusschnittFolder, setAusschnittScale,
|
|
duplicateAusschnitt, addAusschnittFolder, removeAusschnittFolder,
|
|
openAusschnittSettings,
|
|
} from './lib/rhinoBridge'
|
|
|
|
function EditableInline({ value, onCommit, autoEdit, style, fontSize }) {
|
|
const [editing, setEditing] = useState(autoEdit || false)
|
|
const [val, setVal] = useState(value)
|
|
useEffect(() => { setVal(value) }, [value])
|
|
useEffect(() => { if (autoEdit) setEditing(true) }, [autoEdit])
|
|
|
|
const commit = () => {
|
|
const trimmed = (val ?? '').trim()
|
|
if (trimmed && trimmed !== value) onCommit(trimmed)
|
|
else setVal(value)
|
|
setEditing(false)
|
|
}
|
|
if (editing) {
|
|
return (
|
|
<input
|
|
value={val}
|
|
autoFocus
|
|
onChange={(ev) => setVal(ev.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') commit()
|
|
if (ev.key === 'Escape') { setVal(value); setEditing(false) }
|
|
}}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
style={{ ...style, fontSize, padding: '2px 6px' }}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<span
|
|
onDoubleClick={(ev) => { ev.stopPropagation(); setVal(value); setEditing(true) }}
|
|
style={{ ...style, fontSize, cursor: 'text' }}
|
|
>{value}</span>
|
|
)
|
|
}
|
|
|
|
function ScaleCell({ snap, onChange }) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [val, setVal] = useState(snap.scale || '')
|
|
useEffect(() => { setVal(snap.scale || '') }, [snap.scale])
|
|
|
|
const commit = () => {
|
|
onChange(snap.id, val.trim())
|
|
setEditing(false)
|
|
}
|
|
if (editing) {
|
|
return (
|
|
<input
|
|
value={val}
|
|
autoFocus
|
|
onChange={(ev) => setVal(ev.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') commit()
|
|
if (ev.key === 'Escape') { setVal(snap.scale || ''); setEditing(false) }
|
|
}}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
placeholder="1:50"
|
|
style={{ width: 64, fontSize: 10, padding: '2px 6px', textAlign: 'right' }}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<span
|
|
onDoubleClick={(ev) => { ev.stopPropagation(); setEditing(true) }}
|
|
className={snap.scale ? 'chip chip-accent' : 'chip'}
|
|
style={{
|
|
fontSize: 9, cursor: 'text',
|
|
fontFamily: 'var(--font-mono)',
|
|
flexShrink: 0,
|
|
}}
|
|
title={snap.scale ? `Maßstab ${snap.scale} — wird auf Detail angewendet` : 'Doppelklick um Maßstab einzutragen (z.B. 1:50)'}
|
|
>{snap.scale || '—:—'}</span>
|
|
)
|
|
}
|
|
|
|
function OrientationBadge({ orientation }) {
|
|
const variant = orientation === 'perspective' ? {
|
|
icon: 'view_in_ar', color: 'var(--accent)',
|
|
title: 'Perspektive',
|
|
} : orientation === 'horizontal' ? {
|
|
icon: 'align_horizontal_center', color: 'var(--active)',
|
|
title: 'Horizontaler Schnitt (Grundriss)',
|
|
} : {
|
|
icon: 'align_vertical_center', color: 'var(--warn)',
|
|
title: 'Vertikaler Schnitt (Schnitt / Ansicht)',
|
|
}
|
|
return (
|
|
<span
|
|
title={variant.title}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
width: 24, height: 24, flexShrink: 0,
|
|
borderRadius: 999,
|
|
background: 'var(--bg-input)',
|
|
color: variant.color,
|
|
}}
|
|
>
|
|
<Icon name={variant.icon} size={14} />
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function AusschnittCard({ snap, onClick, onContextMenu, onMenuClick, onRename, onScaleChange, onDragStart, onDragEnd, dragging }) {
|
|
return (
|
|
<div
|
|
draggable
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '6px 8px',
|
|
border: '1px solid var(--border-light)',
|
|
borderRadius: 'var(--r)',
|
|
background: '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) => { ev.currentTarget.style.background = 'var(--bg-item-hover)' }}
|
|
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'var(--bg-input)' }}
|
|
>
|
|
<OrientationBadge orientation={snap.orientation} />
|
|
<EditableInline
|
|
value={snap.name}
|
|
onCommit={(n) => onRename(snap.id, n)}
|
|
fontSize={11}
|
|
style={{
|
|
flex: 1, minWidth: 0,
|
|
color: 'var(--text-primary)', fontWeight: 500,
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}
|
|
/>
|
|
<ScaleCell snap={snap} onChange={onScaleChange} />
|
|
<button
|
|
className="btn-icon-sm"
|
|
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
|
|
title="Aktionen"
|
|
>
|
|
<Icon name="more_vert" size={14} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FolderCard({
|
|
name, count, collapsed, onToggle, onContextMenu, onMenuClick,
|
|
onDragOver, onDragLeave, onDrop, dragOver, children,
|
|
}) {
|
|
return (
|
|
<div
|
|
onContextMenu={onContextMenu}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
style={{
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-lg)',
|
|
background: dragOver ? 'var(--accent-dim)' : 'var(--bg-section)',
|
|
marginBottom: 8,
|
|
padding: 8,
|
|
transition: 'background 0.14s, border-color 0.14s',
|
|
borderColor: dragOver ? 'var(--accent-border)' : 'var(--border)',
|
|
}}
|
|
>
|
|
<div
|
|
onClick={onToggle}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
cursor: 'pointer', userSelect: 'none',
|
|
padding: 2,
|
|
}}
|
|
>
|
|
<span style={{
|
|
transform: collapsed ? '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)' }}>{name}</span>
|
|
<span className="chip" style={{ fontSize: 8 }}>{count}</span>
|
|
<button
|
|
className="btn-icon-sm"
|
|
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
|
|
title="Ordner-Aktionen"
|
|
>
|
|
<Icon name="more_vert" size={14} />
|
|
</button>
|
|
</div>
|
|
{!collapsed && (
|
|
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column' }}>
|
|
{children || (
|
|
<div style={{
|
|
padding: '10px 6px', fontSize: 10, color: 'var(--text-muted)',
|
|
textAlign: 'center', fontStyle: 'italic',
|
|
}}>
|
|
Leer — Ausschnitte hier ablegen.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RootDropZone({ children, onDragOver, onDragLeave, onDrop, dragOver, empty }) {
|
|
return (
|
|
<div
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
style={{
|
|
background: dragOver ? 'var(--accent-dim)' : 'transparent',
|
|
border: '1px dashed transparent',
|
|
borderColor: dragOver ? 'var(--accent-border)' : 'transparent',
|
|
borderRadius: 'var(--r-lg)',
|
|
padding: dragOver || empty ? 6 : 0,
|
|
marginBottom: empty ? 0 : 8,
|
|
transition: 'background 0.14s, border-color 0.14s, padding 0.14s',
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function AusschnitteApp() {
|
|
const [snaps, setSnaps] = useState([])
|
|
const [extraFolders, setExtraFolders] = useState([])
|
|
const [newName, setNewName] = useState('')
|
|
const [ctxMenu, setCtxMenu] = useState(null)
|
|
const [collapsed, setCollapsed] = useState({})
|
|
const [draggingId, setDraggingId] = useState(null)
|
|
const [dragTarget, setDragTarget] = useState(null)
|
|
|
|
useEffect(() => {
|
|
onMessage('LIST', ({ snapshots, folders }) => {
|
|
setSnaps(snapshots || [])
|
|
setExtraFolders(folders || [])
|
|
})
|
|
notifyReady()
|
|
const blockContext = (ev) => ev.preventDefault()
|
|
document.addEventListener('contextmenu', blockContext)
|
|
return () => document.removeEventListener('contextmenu', blockContext)
|
|
}, [])
|
|
|
|
const groups = useMemo(() => {
|
|
const map = {}
|
|
snaps.forEach(s => {
|
|
const f = s.folder || ''
|
|
if (!map[f]) map[f] = []
|
|
map[f].push(s)
|
|
})
|
|
return map
|
|
}, [snaps])
|
|
|
|
const allFolders = useMemo(() => {
|
|
const set = new Set(extraFolders)
|
|
snaps.forEach(s => { if (s.folder) set.add(s.folder) })
|
|
return [...set].sort((a, b) => a.localeCompare(b))
|
|
}, [snaps, extraFolders])
|
|
|
|
const handleSave = () => {
|
|
const name = newName.trim() || `Ausschnitt ${snaps.length + 1}`
|
|
saveAusschnitt(name)
|
|
setNewName('')
|
|
}
|
|
|
|
const handleAddFolder = () => {
|
|
const name = window.prompt('Name für neuen Ordner:')
|
|
if (name && name.trim()) addAusschnittFolder(name.trim())
|
|
}
|
|
|
|
const ctxItems = (id) => [
|
|
{ label: 'Wiederherstellen', icon: 'restore', onClick: () => restoreAusschnitt(id) },
|
|
{ label: 'Auf Detail anwenden', icon: 'crop_landscape', onClick: () => applyAusschnittToDetail(id) },
|
|
{ divider: true },
|
|
{ label: 'Ausschnittseinstellungen…', icon: 'tune', onClick: () => openAusschnittSettings(id) },
|
|
{ divider: true },
|
|
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateAusschnitt(id) },
|
|
{ label: 'Aktualisieren', icon: 'sync', onClick: () => updateAusschnitt(id) },
|
|
{ divider: true },
|
|
{ label: 'Löschen', icon: 'delete', danger: true, onClick: () => deleteAusschnitt(id) },
|
|
]
|
|
|
|
const folderCtxItems = (folderName) => [
|
|
{ label: 'Ordner umbenennen', icon: 'edit', onClick: () => {
|
|
const newName = window.prompt('Neuer Ordnername:', folderName)
|
|
if (newName && newName.trim() && newName !== folderName) {
|
|
snaps.filter(s => s.folder === folderName).forEach(s => setAusschnittFolder(s.id, newName.trim()))
|
|
addAusschnittFolder(newName.trim())
|
|
removeAusschnittFolder(folderName)
|
|
}
|
|
}},
|
|
{ divider: true },
|
|
{ label: 'Ordner löschen', icon: 'folder_off', danger: true, onClick: () => {
|
|
if (window.confirm(`Ordner "${folderName}" löschen? Ausschnitte werden zur Wurzel verschoben.`)) {
|
|
removeAusschnittFolder(folderName)
|
|
}
|
|
}},
|
|
]
|
|
|
|
const handleDrop = (folderName) => (ev) => {
|
|
ev.preventDefault()
|
|
setDragTarget(null)
|
|
const id = ev.dataTransfer.getData('text/plain') || draggingId
|
|
if (id) setAusschnittFolder(id, folderName || '')
|
|
setDraggingId(null)
|
|
}
|
|
|
|
const handleDragOver = (folderName) => (ev) => {
|
|
ev.preventDefault()
|
|
ev.dataTransfer.dropEffect = 'move'
|
|
setDragTarget(folderName || 'root')
|
|
}
|
|
|
|
const handleDragLeave = () => () => setDragTarget(null)
|
|
|
|
const renderSnapshot = (s) => (
|
|
<AusschnittCard
|
|
key={s.id}
|
|
snap={s}
|
|
dragging={draggingId === s.id}
|
|
onClick={() => restoreAusschnitt(s.id)}
|
|
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, id: s.id, kind: 'snap' }) }}
|
|
onMenuClick={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, id: s.id, kind: 'snap' })}
|
|
onRename={(id, name) => renameAusschnitt(id, name)}
|
|
onScaleChange={(id, scale) => setAusschnittScale(id, scale)}
|
|
onDragStart={(ev) => {
|
|
ev.dataTransfer.setData('text/plain', s.id)
|
|
ev.dataTransfer.effectAllowed = 'move'
|
|
setDraggingId(s.id)
|
|
}}
|
|
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
|
|
/>
|
|
)
|
|
|
|
const rootItems = groups[''] || []
|
|
const isEmpty = snaps.length === 0 && allFolders.length === 0
|
|
|
|
return (
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column',
|
|
height: '100vh', overflow: 'hidden',
|
|
background: 'var(--bg-base)',
|
|
position: 'relative',
|
|
}}>
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
|
|
{/* Save-Bar als Card */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: 8,
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-lg)',
|
|
background: 'var(--bg-section)',
|
|
marginBottom: 8,
|
|
marginTop: 6,
|
|
}}>
|
|
<input
|
|
value={newName}
|
|
onChange={(ev) => setNewName(ev.target.value)}
|
|
onKeyDown={(ev) => { if (ev.key === 'Enter') handleSave() }}
|
|
placeholder="Name für neuen Ausschnitt…"
|
|
style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font)', minWidth: 0 }}
|
|
/>
|
|
<button className="btn-add" onClick={handleSave} title="Ausschnitt speichern">
|
|
<Icon name="add" size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{isEmpty ? (
|
|
<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="photo_library" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
|
<div style={{ marginTop: 8 }}>Noch keine Ausschnitte.</div>
|
|
<div style={{ marginTop: 4, fontSize: 10 }}>Oben einen Namen eingeben und <Icon name="add" size={11} /> klicken.</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Root-Snapshots */}
|
|
<RootDropZone
|
|
dragOver={dragTarget === 'root'}
|
|
empty={rootItems.length === 0}
|
|
onDragOver={handleDragOver('')}
|
|
onDragLeave={handleDragLeave()}
|
|
onDrop={handleDrop('')}
|
|
>
|
|
{rootItems.map(s => renderSnapshot(s))}
|
|
{rootItems.length === 0 && draggingId && (
|
|
<div style={{
|
|
padding: '8px 14px', fontSize: 10, color: 'var(--text-muted)',
|
|
textAlign: 'center', fontStyle: 'italic',
|
|
}}>Hier ablegen für Wurzel</div>
|
|
)}
|
|
</RootDropZone>
|
|
|
|
{/* Ordner-Cards */}
|
|
{allFolders.map(folder => {
|
|
const isCollapsed = !!collapsed[folder]
|
|
const items = groups[folder] || []
|
|
return (
|
|
<FolderCard
|
|
key={folder}
|
|
name={folder}
|
|
count={items.length}
|
|
collapsed={isCollapsed}
|
|
dragOver={dragTarget === folder}
|
|
onToggle={() => setCollapsed(c => ({ ...c, [folder]: !c[folder] }))}
|
|
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, name: folder, kind: 'folder' }) }}
|
|
onMenuClick={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, name: folder, kind: 'folder' })}
|
|
onDragOver={handleDragOver(folder)}
|
|
onDragLeave={handleDragLeave()}
|
|
onDrop={handleDrop(folder)}
|
|
>
|
|
{items.length > 0 ? items.map(s => renderSnapshot(s)) : null}
|
|
</FolderCard>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
<div style={{ padding: '12px 4px 0', fontSize: 9, color: 'var(--text-muted)',
|
|
lineHeight: 1.6, fontStyle: 'italic' }}>
|
|
Drag & Drop auf Ordner-Card zum Verschieben · Doppelklick auf Name/Maßstab = bearbeiten · ⋮ für Aktionen
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sticky Footer: Anzahl + Ordner erstellen + Reload */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '8px 10px',
|
|
borderTop: '1px solid var(--border)',
|
|
background: 'var(--bg-panel)',
|
|
flexShrink: 0,
|
|
}}>
|
|
<span className="chip" style={{
|
|
fontSize: 9, minWidth: 22, justifyContent: 'center',
|
|
}}>{snaps.length}</span>
|
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1 }}>
|
|
Ausschnitte
|
|
</span>
|
|
<button className="btn-icon-tonal" onClick={handleAddFolder} title="Neuer Ordner">
|
|
<Icon name="create_new_folder" size={14} />
|
|
</button>
|
|
<button className="btn-icon-tonal" onClick={() => listAusschnitte()} title="Aktualisieren">
|
|
<Icon name="refresh" size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x} y={ctxMenu.y}
|
|
items={ctxMenu.kind === 'folder' ? folderCtxItems(ctxMenu.name) : ctxItems(ctxMenu.id)}
|
|
onClose={() => setCtxMenu(null)}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
)
|
|
}
|