feat: logo link to dashboard + ribbon tab Zeichenfläche + settings System tab + ruler

This commit is contained in:
A0 Orchestrator
2026-07-02 14:46:32 +02:00
parent 587efcecd5
commit a6d2e2d718
7 changed files with 209 additions and 18 deletions
+15
View File
@@ -105,6 +105,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
const [gridSize, setGridSize] = useState<number>(20);
const [scaleFactor, setScaleFactor] = useState<number>(1);
// Canvas appearance
const [canvasBgColor, setCanvasBgColor] = useState<string>('#1e1e2e');
const [gridColor, setGridColor] = useState<string>('#3a3a4a');
const [rulerEnabled, setRulerEnabled] = useState<boolean>(true);
// Right sidebar
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
@@ -1527,11 +1532,18 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
onOpenRightDrawer={() => setMobileRightOpen(true)}
unit={unit}
onUnitChange={handleUnitChange}
onNavigateBack={onNavigateBack}
/>
<RibbonBar
activeTab={activeRibbonTab}
onTabChange={setActiveRibbonTab}
onAction={handleRibbonAction}
canvasBgColor={canvasBgColor}
gridColor={gridColor}
rulerEnabled={rulerEnabled}
onCanvasBgColorChange={setCanvasBgColor}
onGridColorChange={setGridColor}
onToggleRuler={() => setRulerEnabled((p) => !p)}
/>
<div className={`app-body${leftSidebarCollapsed ? ' left-collapsed' : ''}${rightSidebarCollapsed ? ' right-collapsed' : ''}`}>
<LeftSidebar
@@ -1593,6 +1605,9 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
unit={unit}
gridSize={gridSize}
scaleFactor={scaleFactor}
canvasBgColor={canvasBgColor}
gridColor={gridColor}
rulerEnabled={rulerEnabled}
/>
<RightSidebar
activePanel={activeRightPanel}
+110 -4
View File
@@ -21,6 +21,9 @@ export interface RenderOptions {
unit?: 'mm' | 'cm' | 'm';
scaleFactor?: number;
showGridLabels?: boolean;
canvasBgColor?: string;
gridColor?: string;
rulerEnabled?: boolean;
}
export interface SelectionState {
@@ -73,6 +76,9 @@ export class RenderEngine {
backgroundOffsetY: 0,
backgroundRotation: 0,
backgroundOpacity: 0.5,
canvasBgColor: '#1e1e2e',
gridColor: '#3a3a4a',
rulerEnabled: true,
};
this.selection = {
selectedIds: new Set(),
@@ -145,12 +151,12 @@ export class RenderEngine {
const w = this.canvas.width / this.dpr;
const h = this.canvas.height / this.dpr;
this.ctx.save();
this.ctx.fillStyle = '#1e1e2e';
this.ctx.fillStyle = this.options.canvasBgColor || '#1e1e2e';
this.ctx.fillRect(0, 0, w, h);
if (this.options.showGrid) this.drawGrid(w, h);
if (this.options.backgroundSrc) this.drawBackground();
this.drawRuler(w, h);
const viewport = this.zoomPan.getViewport();
const visibleElements = this.spatialIndex.search({
minX: viewport.minX, minY: viewport.minY,
@@ -190,7 +196,7 @@ export class RenderEngine {
const offsetX = this.zoomPan.getTransform().e % gridSize;
const offsetY = this.zoomPan.getTransform().f % gridSize;
this.ctx.strokeStyle = '#2a2a3e';
this.ctx.strokeStyle = this.options.gridColor || '#3a3a4a';
this.ctx.lineWidth = 1;
this.ctx.beginPath();
for (let x = offsetX; x < w; x += gridSize) {
@@ -208,7 +214,8 @@ export class RenderEngine {
if (majorSize >= 20) {
const majOffX = this.zoomPan.getTransform().e % majorSize;
const majOffY = this.zoomPan.getTransform().f % majorSize;
this.ctx.strokeStyle = '#33334a';
// Major lines use a brighter variant of gridColor
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 15);
this.ctx.beginPath();
for (let x = majOffX; x < w; x += majorSize) {
this.ctx.moveTo(x, 0);
@@ -272,6 +279,105 @@ export class RenderEngine {
}
}
private lightenColor(hex: string, percent: number): string {
const num = parseInt(hex.replace('#', ''), 16);
if (isNaN(num)) return hex;
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0x0000FF) + Math.round(255 * percent / 100));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
private drawRuler(w: number, h: number): void {
if (!this.options.rulerEnabled) return;
const scale = this.zoomPan.getScale();
const transform = this.zoomPan.getTransform();
const unit = this.options.unit || 'mm';
const scaleFactor = this.options.scaleFactor || 1;
const rulerSize = 20;
this.ctx.save();
this.ctx.fillStyle = this.options.canvasBgColor || '#1e1e2e';
this.ctx.fillRect(0, 0, w, rulerSize);
this.ctx.fillRect(0, 0, rulerSize, h);
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.moveTo(0, rulerSize);
this.ctx.lineTo(w, rulerSize);
this.ctx.moveTo(rulerSize, 0);
this.ctx.lineTo(rulerSize, h);
this.ctx.stroke();
// Determine tick spacing in world units (aim for ~50px between major ticks)
const targetPx = 50;
const worldPerPx = 1 / scale;
const rawTickSpacing = targetPx * worldPerPx;
const niceSpacings = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
let tickSpacing = niceSpacings[0];
for (const ns of niceSpacings) {
if (ns >= rawTickSpacing) { tickSpacing = ns; break; }
tickSpacing = ns;
}
const tickPx = tickSpacing * scale;
this.ctx.fillStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 50);
this.ctx.font = '9px sans-serif';
this.ctx.textBaseline = 'middle';
// X-axis ruler (top)
this.ctx.textAlign = 'center';
const xStartWorld = Math.floor((-transform.e / scale) / tickSpacing) * tickSpacing;
for (let i = 0; ; i++) {
const screenX = transform.e + (xStartWorld + i * tickSpacing) * scale;
if (screenX < rulerSize) continue;
if (screenX > w) break;
// Tick mark
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
this.ctx.beginPath();
this.ctx.moveTo(screenX, rulerSize - 6);
this.ctx.lineTo(screenX, rulerSize);
this.ctx.stroke();
// Label
const worldVal = (xStartWorld + i * tickSpacing) * scaleFactor;
let label: string;
switch (unit) {
case 'mm': label = `${worldVal.toFixed(0)}`; break;
case 'cm': label = `${(worldVal / 10).toFixed(0)}`; break;
case 'm': label = `${(worldVal / 1000).toFixed(2)}`; break;
}
this.ctx.fillText(label, screenX, rulerSize / 2);
}
// Y-axis ruler (left)
this.ctx.textAlign = 'right';
this.ctx.textBaseline = 'middle';
const yStartWorld = Math.floor((-transform.f / scale) / tickSpacing) * tickSpacing;
for (let i = 0; ; i++) {
const screenY = transform.f + (yStartWorld + i * tickSpacing) * scale;
if (screenY < rulerSize) continue;
if (screenY > h) break;
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
this.ctx.beginPath();
this.ctx.moveTo(rulerSize - 6, screenY);
this.ctx.lineTo(rulerSize, screenY);
this.ctx.stroke();
const worldVal = (yStartWorld + i * tickSpacing) * scaleFactor;
let label: string;
switch (unit) {
case 'mm': label = `${worldVal.toFixed(0)}`; break;
case 'cm': label = `${(worldVal / 10).toFixed(0)}`; break;
case 'm': label = `${(worldVal / 1000).toFixed(2)}`; break;
}
this.ctx.fillText(label, rulerSize - 2, screenY);
}
this.ctx.restore();
this.ctx.textAlign = 'start';
this.ctx.textBaseline = 'alphabetic';
}
private drawBackground(): void {
// Background image rendering with transform
const img = new Image();
+3 -1
View File
@@ -20,6 +20,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, externalSelectionIds, selectedTemplate, bgConfig, remoteCursors, zoomCommand,
groups,
unit = 'mm', gridSize = 20, scaleFactor = 1,
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoomPanRef = useRef<ZoomPanController | null>(null);
@@ -213,6 +214,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
renderEngine.setOptions({ showSnapPoints: snapEnabled });
renderEngine.setOptions({ showOrtho: orthoEnabled });
renderEngine.setOptions({ gridSize, unit, scaleFactor, showGridLabels: true });
renderEngine.setOptions({ canvasBgColor, gridColor, rulerEnabled });
interaction.setSnapEnabled(snapEnabled);
interaction.setOrthoEnabled(orthoEnabled);
interaction.setPolarEnabled(polarEnabled);
@@ -220,7 +222,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
interaction.setGridSize(gridSize);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor]);
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor, canvasBgColor, gridColor, rulerEnabled]);
// Sync active tool
useEffect(() => {
+56 -1
View File
@@ -6,11 +6,16 @@ const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
{ id: 'insert', label: 'Einfügen', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> },
{ id: 'format', label: 'Format', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg> },
{ id: 'view', label: 'Ansicht', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg> },
{ id: 'canvas', label: 'Zeichenfläche', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg> },
{ id: 'tools', label: 'Extras', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg> },
];
const RibbonBar: React.FC<RibbonBarProps> = ({ activeTab, onTabChange, onAction }) => {
const RibbonBar: React.FC<RibbonBarProps> = ({
activeTab, onTabChange, onAction,
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
}) => {
return (
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
<div className="ribbon-tabs" role="tablist">
@@ -215,6 +220,56 @@ const RibbonBar: React.FC<RibbonBarProps> = ({ activeTab, onTabChange, onAction
</>
)}
{/* ===== CANVAS TAB ===== */}
{activeTab === 'canvas' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<div className="ribbon-color-control" title="Hintergrundfarbe der Zeichenfläche">
<label className="ribbon-color-label">Hintergrund</label>
<input
type="color"
value={canvasBgColor}
onChange={(e) => onCanvasBgColorChange?.(e.target.value)}
style={{ width: '32px', height: '32px', border: '1px solid var(--color-border)', borderRadius: '4px', cursor: 'pointer', padding: 0, background: 'none' }}
/>
</div>
<div className="ribbon-color-control" title="Gitterfarbe">
<label className="ribbon-color-label">Gitterfarbe</label>
<input
type="color"
value={gridColor}
onChange={(e) => onGridColorChange?.(e.target.value)}
style={{ width: '32px', height: '32px', border: '1px solid var(--color-border)', borderRadius: '4px', cursor: 'pointer', padding: 0, background: 'none' }}
/>
</div>
</div>
<div className="ribbon-group-label">Farben</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button
className={`ribbon-btn${rulerEnabled ? ' active' : ''}`}
title="Lineal ein/aus"
aria-pressed={rulerEnabled}
onClick={() => onToggleRuler?.()}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 8h3"/><path d="M3 13h3"/><path d="M3 18h3"/><path d="M8 3v3"/><path d="M13 3v3"/><path d="M18 3v3"/></svg>
<span>Lineal</span>
</button>
<button className="ribbon-btn" title="Raster ein/aus (F7)" onClick={() => onAction('grid')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
<span>Raster</span>
</button>
</div>
<div className="ribbon-group-label">Anzeige</div>
</div>
</>
)}
{/* ===== TOOLS TAB ===== */}
{activeTab === 'tools' && (
<>
+3 -3
View File
@@ -14,7 +14,7 @@ export interface SettingsModalProps {
onScaleFactorChange?: (factor: number) => void;
}
type SettingsTab = 'personal' | 'password' | 'language' | 'theme' | 'units' | 'users' | 'plugins' | 'ai';
type SettingsTab = 'personal' | 'password' | 'system' | 'theme' | 'units' | 'users' | 'plugins' | 'ai';
const SettingsModal: React.FC<SettingsModalProps> = ({
open, onClose,
@@ -57,7 +57,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({
const tabs: Array<{ id: SettingsTab; label: string; adminOnly?: boolean }> = [
{ id: 'personal', label: 'Persönlich' },
{ id: 'password', label: 'Passwort' },
{ id: 'language', label: 'Sprache' },
{ id: 'system', label: 'System' },
{ id: 'theme', label: 'Theme' },
{ id: 'units', label: 'Einheiten' },
{ id: 'users', label: 'Benutzer', adminOnly: true },
@@ -118,7 +118,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({
<button className="settings-btn" onClick={handlePasswordChange}>Passwort ändern</button>
</div>
);
case 'language':
case 'system':
return (
<div className="settings-tab-content">
<label className="settings-label">Sprache</label>
+11 -8
View File
@@ -5,7 +5,7 @@ import type { UnitType } from '../utils/format';
const Topbar: React.FC<TopbarProps> = ({
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
unit = 'mm', onUnitChange,
unit = 'mm', onUnitChange, onNavigateBack,
}) => {
return (
<header className="topbar" role="banner">
@@ -13,10 +13,17 @@ const Topbar: React.FC<TopbarProps> = ({
<button className="hamburger-btn" aria-label="Werkzeuge öffnen" onClick={onOpenLeftDrawer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div className="app-logo" aria-hidden="true">
<button
className="app-logo"
aria-label="Zum Dashboard"
onClick={onNavigateBack}
style={{ cursor: onNavigateBack ? 'pointer' : 'default', border: 'none', background: 'none', padding: 0, display: 'flex', alignItems: 'center', gap: '4px', transition: 'opacity 0.15s' }}
onMouseEnter={(e) => { if (onNavigateBack) e.currentTarget.style.opacity = '0.7'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
</div>
<span className="app-name">web-cad</span>
<span className="app-name">web-cad</span>
</button>
<span style={{ color: 'var(--color-border-strong)', margin: '0 4px' }}>|</span>
<button className="project-name" aria-label="Projekt wechseln">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
@@ -70,10 +77,6 @@ const Topbar: React.FC<TopbarProps> = ({
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<select className="lang-select" aria-label="Sprache wählen" defaultValue="de">
<option value="de">DE</option>
<option value="en">EN</option>
</select>
<button className="icon-btn-top mobile-drawer-toggle" aria-label="Panel öffnen" title="Panel öffnen" onClick={onOpenRightDrawer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
</button>
+11 -1
View File
@@ -9,7 +9,7 @@ import type { UserCursor } from '../crdt';
import type { UnitType } from '../utils/format';
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'tools' | 'ki';
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'canvas' | 'tools' | 'ki';
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki';
export type Theme = 'light' | 'dark';
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
@@ -70,6 +70,7 @@ export interface TopbarProps {
onOpenRightDrawer?: () => void;
unit?: UnitType;
onUnitChange?: (unit: UnitType) => void;
onNavigateBack?: () => void;
}
export interface SettingsModalProps {
@@ -87,6 +88,12 @@ export interface RibbonBarProps {
activeTab: RibbonTab;
onTabChange: (tab: RibbonTab) => void;
onAction: (action: string) => void;
canvasBgColor?: string;
gridColor?: string;
rulerEnabled?: boolean;
onCanvasBgColorChange?: (color: string) => void;
onGridColorChange?: (color: string) => void;
onToggleRuler?: () => void;
}
export interface LeftSidebarProps {
@@ -151,6 +158,9 @@ export interface CanvasAreaProps {
unit?: UnitType;
gridSize?: number;
scaleFactor?: number;
canvasBgColor?: string;
gridColor?: string;
rulerEnabled?: boolean;
}
export interface RightSidebarProps {