i18n DE/EN + DossierSettings panel + English file renames
i18n: - src/i18n/de.json + en.json: 200+ keys covering all main panels - src/i18n/index.js: t(key, vars) reads window.DOSSIER_LANG - panel_base.py: injects window.DOSSIER_LANG from dossier_settings.json - EbenenManager, GeschossManager, AusschnitteApp, LayoutsApp: all context menus and main labels use t() DossierSettings panel: - DossierSettingsApp.jsx: language toggle (DE/EN pill) + launcher status - toolbar.py: OPEN_SETTINGS opens new Rhino-hosted satellite window, SAVE_LANG writes lang to dossier_settings.json + reloads all panels File renames (JSX → English): - ZeichnungsebenenApp → DrawingLevelsApp - GeschossManager/Dialog/Settings → Floor* - AusschnitteApp/Settings → Viewports* - EbenenManager/Settings → Layer* - GestaltungApp → StylesApp, OberleisteApp → ToolbarApp - WerkzeugeApp → ToolsApp, DimensionenApp → DimensionsApp - MassstabApp → ScaleApp, KameraApp → CameraApp - MasseSettingsApp → UnitsSettingsApp - ConfirmDeleteEbene → ConfirmDeleteLayer - AusschnittLayerDialog → ViewportLayerDialog Python module renames: - rhinopanel.py → layers_panel.py - oberleiste.py → toolbar.py - gestaltung.py → styles.py - werkzeuge.py → tools.py - dimensionen.py → dimensions.py - startup.py _MODULE_TO_PY updated, all cross-imports fixed
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Karim Gabriele Varano
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import ContextMenu from './components/ContextMenu'
|
||||
import { t } from './i18n/index.js'
|
||||
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: 20, height: 20, flexShrink: 0,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-input)',
|
||||
color: variant.color,
|
||||
}}
|
||||
>
|
||||
<Icon name={variant.icon} size={12} />
|
||||
</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: 6,
|
||||
padding: '2px 8px 2px 4px',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-input)',
|
||||
cursor: 'grab', userSelect: 'none',
|
||||
marginBottom: 3,
|
||||
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(t('viewports.new_folder_name'))
|
||||
if (name && name.trim()) addAusschnittFolder(name.trim())
|
||||
}
|
||||
|
||||
const ctxItems = (id) => [
|
||||
{ label: t('viewports.restore'), icon: 'restore', onClick: () => restoreAusschnitt(id) },
|
||||
{ label: t('viewports.apply_to_detail'),icon: 'crop_landscape', onClick: () => applyAusschnittToDetail(id) },
|
||||
{ divider: true },
|
||||
{ label: t('viewports.settings'), icon: 'tune', onClick: () => openAusschnittSettings(id) },
|
||||
{ divider: true },
|
||||
{ label: t('common.duplicate'), icon: 'content_copy', onClick: () => duplicateAusschnitt(id) },
|
||||
{ label: t('viewports.update'), icon: 'sync', onClick: () => updateAusschnitt(id) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: 'delete', danger: true, onClick: () => deleteAusschnitt(id) },
|
||||
]
|
||||
|
||||
const folderCtxItems = (folderName) => [
|
||||
{ label: t('common.rename_folder'), icon: 'edit', onClick: () => {
|
||||
const newName = window.prompt(t('common.new_folder_name'), 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: t('common.delete_folder'), icon: 'folder_off', danger: true, onClick: () => {
|
||||
if (window.confirm(`${t('common.delete_folder')} "${folderName}"? ${t('viewports.delete_folder_confirm')}`)) {
|
||||
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 — kein Outer-Border mehr, nur das Pill-Input + Add-Button. */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user