task(H1): simple/pro mode - uiModeService with localStorage persistence, ribbon basic-tag filter, commandline pro-only, topbar toggle
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import type { CommandLineProps } from '../types/ui.types';
|
import type { CommandLineProps } from '../types/ui.types';
|
||||||
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
|
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
|
||||||
|
import { useUIMode } from '../services/uiModeService';
|
||||||
|
|
||||||
const defaultHistory = [
|
const defaultHistory = [
|
||||||
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
|
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
|
||||||
@@ -20,6 +21,9 @@ const categoryColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
|
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
|
||||||
|
const uiMode = useUIMode();
|
||||||
|
if (uiMode === 'simple') return null;
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
||||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { getUIMode, useUIMode } from '../services/uiModeService';
|
||||||
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
||||||
import { pluginRegistry } from '../plugins';
|
import { pluginRegistry } from '../plugins';
|
||||||
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
||||||
@@ -19,6 +20,8 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
||||||
onIsV2ToolActive,
|
onIsV2ToolActive,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uiMode = useUIMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
||||||
<div className="ribbon-tabs" role="tablist">
|
<div className="ribbon-tabs" role="tablist">
|
||||||
@@ -47,7 +50,9 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
|
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
|
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
|
||||||
{pluginRegistry.getToolsV2().map((tool) => {
|
{pluginRegistry.getToolsV2()
|
||||||
|
.filter((tool) => uiMode === 'pro' || (tool.manifest.tags ?? []).includes('basic'))
|
||||||
|
.map((tool) => {
|
||||||
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { TopbarProps } from '../types/ui.types';
|
import type { TopbarProps } from '../types/ui.types';
|
||||||
import type { UnitType } from '../utils/format';
|
import type { UnitType } from '../utils/format';
|
||||||
|
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
|
||||||
|
|
||||||
const Topbar: React.FC<TopbarProps> = ({
|
const Topbar: React.FC<TopbarProps> = ({
|
||||||
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
||||||
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
||||||
unit = 'mm', onUnitChange, onNavigateBack,
|
unit = 'mm', onUnitChange, onNavigateBack,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uiMode = useUIMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar" role="banner">
|
<header className="topbar" role="banner">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@@ -33,6 +36,14 @@ const Topbar: React.FC<TopbarProps> = ({
|
|||||||
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
|
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
|
<button
|
||||||
|
className="icon-btn-top"
|
||||||
|
title={uiMode === 'pro' ? 'Zum einfachen Modus wechseln' : 'Zum Profi-Modus wechseln'}
|
||||||
|
aria-label={uiMode === 'pro' ? 'Zum einfachen Modus wechseln' : 'Zum Profi-Modus wechseln'}
|
||||||
|
onClick={toggleUIMode}
|
||||||
|
>
|
||||||
|
{uiMode === 'pro' ? '😊' : '🛠'}
|
||||||
|
</button>
|
||||||
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Task H1 – UI-Modus simple/pro.
|
||||||
|
*
|
||||||
|
* - 'pro': vollständige Werkzeugpalette + CommandLine (Default)
|
||||||
|
* - 'simple': nur basic-Tools, keine CommandLine
|
||||||
|
*
|
||||||
|
* Persistenz über localStorage ('webcad.uiMode'), Subscribe-Set für
|
||||||
|
* Live-Updates (RibbonBar/CommandLine/Topbar hören zu). Bewusst KEIN
|
||||||
|
* zustand (Q2-Entscheidung wurde nie umgesetzt; Muster folgt dem
|
||||||
|
* etablierten documentService/featureFlags-Stil des Projekts).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type UIMode = 'simple' | 'pro';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'webcad.uiMode';
|
||||||
|
const listeners = new Set<(mode: UIMode) => void>();
|
||||||
|
|
||||||
|
function readStored(): UIMode {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === 'simple' || raw === 'pro') return raw;
|
||||||
|
} catch {
|
||||||
|
// localStorage nicht verfügbar — Default
|
||||||
|
}
|
||||||
|
return 'pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: UIMode = readStored();
|
||||||
|
|
||||||
|
/** Aktueller UI-Modus (Default pro, persistiert). */
|
||||||
|
export function getUIMode(): UIMode {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wechselt den Modus und persistiert. */
|
||||||
|
export function setUIMode(mode: UIMode): void {
|
||||||
|
current = mode;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Persistenz optional
|
||||||
|
}
|
||||||
|
for (const cb of listeners) cb(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle simple ↔ pro. */
|
||||||
|
export function toggleUIMode(): void {
|
||||||
|
setUIMode(current === 'pro' ? 'simple' : 'pro');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe auf Moduswechsel; gibt Unsubscribe zurück. */
|
||||||
|
export function subscribeUIMode(cb: (mode: UIMode) => void): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => { listeners.delete(cb); };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React-Hook: aktueller Modus mit Re-Render bei Wechsel. */
|
||||||
|
export function useUIMode(): UIMode {
|
||||||
|
const [mode, setMode] = useState<UIMode>(current);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = subscribeUIMode(setMode);
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Task H1 – simple/pro Mode.
|
||||||
|
*
|
||||||
|
* uiModeService: persistierter UI-Modus ('simple' | 'pro', Default 'pro'),
|
||||||
|
* localStorage-Persistenz, Subscribe für Live-Updates.
|
||||||
|
* RibbonBar filtert im simple-Modus auf tools mit tags 'basic'.
|
||||||
|
* CommandLine ist pro-only (im simple-Modus versteckt).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
getUIMode, setUIMode, subscribeUIMode, UIMode,
|
||||||
|
} from '../src/services/uiModeService';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// ─── uiModeService ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('H1: uiModeService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Default ist pro (Vollständige Werkzeugpalette)', () => {
|
||||||
|
expect(getUIMode()).toBe('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setUIMode simple persistiert in localStorage', () => {
|
||||||
|
setUIMode('simple');
|
||||||
|
expect(getUIMode()).toBe('simple');
|
||||||
|
expect(localStorage.getItem('webcad.uiMode')).toBe('simple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Persistiert über Reload hinweg (localStorage gesetzt vor erstem get)', () => {
|
||||||
|
localStorage.setItem('webcad.uiMode', 'simple');
|
||||||
|
expect(getUIMode()).toBe('simple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribeUIMode feuert bei Wechsel und unsubscribed korrekt', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
const off = subscribeUIMode(cb);
|
||||||
|
setUIMode('simple');
|
||||||
|
expect(cb).toHaveBeenCalledWith('simple');
|
||||||
|
setUIMode('pro');
|
||||||
|
expect(cb).toHaveBeenCalledWith('pro');
|
||||||
|
off();
|
||||||
|
cb.mockClear();
|
||||||
|
setUIMode('simple');
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
// Aufräumen für weitere Tests
|
||||||
|
setUIMode('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungültiger localStorage-Wert fällt auf pro zurück', () => {
|
||||||
|
localStorage.setItem('webcad.uiMode', 'gibts-nicht');
|
||||||
|
expect(getUIMode()).toBe('pro');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── RibbonBar simple-Filter ─────────────────────────────
|
||||||
|
|
||||||
|
describe('H1: RibbonBar simple-Filter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setUIMode('pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pro-Modus: alle V2-Tools sichtbar (basic + pro-Tags)', async () => {
|
||||||
|
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||||
|
registerBuiltinPlugins();
|
||||||
|
pluginRegistry.initDefaults();
|
||||||
|
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||||
|
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||||
|
const all = screen.getAllByRole('button').length;
|
||||||
|
expect(all).toBeGreaterThan(10);
|
||||||
|
cleanup();
|
||||||
|
setUIMode('simple');
|
||||||
|
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||||
|
const simpleCount = screen.getAllByRole('button').length;
|
||||||
|
expect(simpleCount).toBeLessThan(all);
|
||||||
|
expect(simpleCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('simple-Modus: pro-Tools (rotate/scale/mirror) fehlen, basic (line) bleibt', async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
setUIMode('simple');
|
||||||
|
const { registerBuiltinPlugins, pluginRegistry } = await import('../src/plugins');
|
||||||
|
registerBuiltinPlugins();
|
||||||
|
pluginRegistry.initDefaults();
|
||||||
|
const RibbonBar = (await import('../src/components/RibbonBar')).default;
|
||||||
|
render(<RibbonBar activeTab="canvas" onTabChange={() => {}} onAction={() => {}} />);
|
||||||
|
const html = document.body.innerHTML;
|
||||||
|
expect(html).toContain('data-v2tool="line"');
|
||||||
|
expect(html).not.toContain('data-v2tool="rotate"');
|
||||||
|
expect(html).not.toContain('data-v2tool="mirror"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CommandLine-Komponente: pro-only rendering via useUIMode', async () => {
|
||||||
|
const { useUIMode } = await import('../src/services/uiModeService');
|
||||||
|
const CommandLine = (await import('../src/components/CommandLine')).default;
|
||||||
|
setUIMode('pro');
|
||||||
|
const { container: proContainer } = render(<CommandLine history={[]} onCommand={() => {}} />);
|
||||||
|
expect(proContainer.querySelector('.cmdline')).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
setUIMode('simple');
|
||||||
|
const { container: simpleContainer } = render(<CommandLine history={[]} onCommand={() => {}} />);
|
||||||
|
expect(simpleContainer.querySelector('.cmdline')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Topbar-Toggle wechselt Mode', async () => {
|
||||||
|
const Topbar = (await import('../src/components/Topbar')).default;
|
||||||
|
setUIMode('pro');
|
||||||
|
render(<Topbar projectName="Testprojekt" savedStatus="Gespeichert" onUndo={() => {}} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />);
|
||||||
|
const btn = screen.getByTitle(/Modus.*wechseln|Einfach.*Modus|Profi-Modus/i);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(getUIMode()).toBe('simple');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user