diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37532e0..8c78ecf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,10 @@ import StatusBar from './components/StatusBar'; import LayoutBar from './components/LayoutBar'; import MobileDrawers from './components/MobileDrawers'; import BackgroundImport from './components/BackgroundImport'; +import BOMDialog from './components/BOMDialog'; +import { createAutosaveController } from './services/autosaveService'; +import { createKeyboardShortcuts } from './services/keyboardShortcuts'; +import { getActiveDocument } from './kernel/document/documentService'; import HistoryPanel from './components/HistoryPanel'; import V2HistoryPanel from './components/V2HistoryPanel'; import GuestPanel from './components/GuestPanel'; @@ -178,8 +182,78 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // History panel const [historyPanelOpen, setHistoryPanelOpen] = useState(false); + const [bomDialogOpen, setBomDialogOpen] = useState(false); const [guestPanelOpen, setGuestPanelOpen] = useState(false); + // ── Task CAD-9: BOM-Action ── + useEffect(() => { + if (!drawingId || !token) return; + // noop: BOM-Action wird über handleRibbonAction verdrahtet (unten) + }, [drawingId, token]); + + // ── Task CAD-12a/12b: Autosave + Keyboard-Shortcuts ── + useEffect(() => { + if (!drawingId || !token) return; + + // Autosave: Status im Topbar sichtbar machen (CAD-12a) + const autosave = createAutosaveController({ + isConnected: () => collab.status === 'connected', + hasUnsavedChanges: () => elements.length > 0, + performFullSave: async () => { + await Promise.all( + elements.map((el) => + updateElement(token, el.id, el).catch(() => undefined), + ), + ); + }, + onStatusChange: (s) => { + if (s.state === 'saving') setSavedStatus('Speichert…'); + else if (s.state === 'saved') setSavedStatus('Autom. gespeichert'); + else if (s.state === 'offline') setSavedStatus('Offline'); + }, + }); + + // Keyboard-Shortcuts (CAD-12b) + const ks = createKeyboardShortcuts({ + escape: () => setActiveTool('select'), + delete: () => { + const ids = selectedElementIdsRef.current; + if (ids.length > 0) handleElementsDeleted(ids); + }, + selectAll: () => { + setSelectedElementIds(elements.map((el) => el.id)); + }, + duplicate: () => { + const ids = selectedElementIdsRef.current; + if (ids.length > 0) { + elements + .filter((el) => ids.includes(el.id)) + .forEach((el) => { + const copy = { + ...el, + id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + x: el.x + 20, + y: el.y + 20, + } as typeof el; + collab.setElement(copy); + }); + } + }, + toggleGrid: () => setGridEnabled((g) => !g), + toggleOrtho: () => setOrthoEnabled((o) => !o), + toggleSnap: () => setSnapEnabled((s) => !s), + }); + + const keyHandler = (e: KeyboardEvent) => ks.handler(e); + document.addEventListener('keydown', keyHandler); + + return () => { + autosave.destroy(); + document.removeEventListener('keydown', keyHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [drawingId, token, collab.status]); + // Export format selector (cross-browser replacement for nwsave) const [exportFormatOpen, setExportFormatOpen] = useState(false); @@ -1164,6 +1238,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // ─── Background ─── if (action === 'background-import') { setBgImportOpen(true); return; } + if (action === 'bom') { setBomDialogOpen(true); return; } // ─── KI actions ─── if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; } @@ -1749,6 +1824,12 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onApply={handleBgApply} backgroundService={bgServiceRef.current} /> + {bomDialogOpen && getActiveDocument() && ( + setBomDialogOpen(false)} + /> + )} {historyPanelOpen && (V2_TOOLS_ENABLED ? ( setHistoryPanelOpen(false)} diff --git a/frontend/src/services/autosaveService.ts b/frontend/src/services/autosaveService.ts new file mode 100644 index 0000000..4c378e6 --- /dev/null +++ b/frontend/src/services/autosaveService.ts @@ -0,0 +1,77 @@ +/** + * Task CAD-12a – Autosave-Service. + * + * Der Yjs-CRDT speichert bereits automatisch über WebSocket → LevelDB. + * Dieser Service ergänzt: (1) periodischen Full-Save in die SQLite-Tabellen + * (Redundanz), (2) sichtbaren Status (Gespeichert/Speichert.../Offline), + * (3) Reconnect-Handling (CRDT synced nach). + */ + +export const AUTOSAVE_INTERVAL_MS = 30_000; + +export interface AutosaveStatus { + state: 'idle' | 'saved' | 'saving' | 'offline'; + lastSaved: Date | null; +} + +export interface AutosaveDeps { + /** Ist der CRDT-WebSocket verbunden? */ + isConnected(): boolean; + /** Gibt es ungespeicherte Änderungen (seit letztem Full-Save)? */ + hasUnsavedChanges(): boolean; + /** Speichert den Zustand (Promise). */ + performFullSave(): Promise; + /** Status-Callback (UI-Update). */ + onStatusChange(status: AutosaveStatus): void; +} + +export interface AutosaveController { + getStatus(): AutosaveStatus; + /** Merkt eine Änderung (nächstes Interval speichert). */ + markChanges(): void; + destroy(): void; +} + +export function createAutosaveController(deps: AutosaveDeps): AutosaveController { + let state: AutosaveStatus['state'] = 'idle'; + let lastSaved: Date | null = null; + let dirty = false; + let timer: ReturnType | null = null; + let destroyed = false; + + const setStatus = (s: AutosaveStatus['state']): void => { + state = s; + deps.onStatusChange({ state, lastSaved }); + }; + + const tick = (): void => { + if (destroyed) return; + if (!deps.isConnected()) { setStatus('offline'); return; } + if (!dirty && !deps.hasUnsavedChanges()) { setStatus('saved'); return; } + setStatus('saving'); + deps.performFullSave() + .then(() => { + lastSaved = new Date(); + dirty = false; + setStatus('saved'); + }) + .catch(() => { + setStatus('offline'); + }); + }; + + // Init: Status sofort melden + if (!deps.isConnected()) setStatus('offline'); + else setStatus('saved'); + + timer = setInterval(tick, AUTOSAVE_INTERVAL_MS); + + return { + getStatus: () => ({ state, lastSaved }), + markChanges: () => { dirty = true; }, + destroy: () => { + destroyed = true; + if (timer) { clearInterval(timer); timer = null; } + }, + }; +} diff --git a/frontend/src/services/keyboardShortcuts.ts b/frontend/src/services/keyboardShortcuts.ts new file mode 100644 index 0000000..a6468ac --- /dev/null +++ b/frontend/src/services/keyboardShortcuts.ts @@ -0,0 +1,78 @@ +/** + * Task CAD-12b – Globale Keyboard-Shortcuts fuer den Editor. + * + * ESC = aktives Tool abbrechen + * Delete/Backspace = Auswahl loeschen + * Strg+A = alles auswaehlen + * Strg+D = Auswahl duplizieren + * F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle + * + * Input-Felder (INPUT, TEXTAREA, contentEditable) werden ignoriert. + */ + +export interface ShortcutAction { + escape: () => void; + delete: () => void; + selectAll: () => void; + duplicate: () => void; + toggleGrid: () => void; + toggleOrtho: () => void; + toggleSnap: () => void; +} + +export interface KeyboardShortcutsController { + handler: (e: KeyboardEvent) => void; + destroy(): void; +} + +export function createKeyboardShortcuts(actions: ShortcutAction): KeyboardShortcutsController { + const isInputElement = (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + if (!el) return false; + const tag = el.tagName?.toUpperCase() ?? ''; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true; + }; + + const handler = (e: KeyboardEvent): void => { + if (isInputElement(e.target)) return; + const ctrl = e.ctrlKey || e.metaKey; + switch (e.key) { + case 'Escape': + e.preventDefault(); + actions.escape(); + break; + case 'Delete': + case 'Backspace': + e.preventDefault(); + actions.delete(); + break; + case 'a': + case 'A': + if (ctrl) { e.preventDefault(); actions.selectAll(); } + break; + case 'd': + case 'D': + if (ctrl) { e.preventDefault(); actions.duplicate(); } + break; + case 'F7': + e.preventDefault(); + actions.toggleGrid(); + break; + case 'F8': + e.preventDefault(); + actions.toggleOrtho(); + break; + case 'F3': + e.preventDefault(); + actions.toggleSnap(); + break; + } + }; + + return { + handler, + destroy: () => { + // cleanup: caller removes listener + }, + }; +} diff --git a/frontend/tests/autosave.test.ts b/frontend/tests/autosave.test.ts new file mode 100644 index 0000000..738a43b --- /dev/null +++ b/frontend/tests/autosave.test.ts @@ -0,0 +1,130 @@ +/** + * Task CAD-12a - Autosave-Service. + * + * Der Yjs-CRDT speichert bereits automatisch ueber WebSocket. + * Dieser Service macht das SICHTBAR und ergaenzt: + * - Save-Status-Text (Automatisch gespeichert / Offline / Speichert...) + * - Periodischer Full-Save in SQLite (Redundanz) + * - Reconnect-Handling (CRDT synced automatisch nach) + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createAutosaveController, + type AutosaveStatus, + AUTOSAVE_INTERVAL_MS, +} from '../src/services/autosaveService'; + +describe('CAD-12a: autosaveService', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('AUTOSAVE_INTERVAL_MS ist 30 Sekunden', () => { + expect(AUTOSAVE_INTERVAL_MS).toBe(30000); + }); + + it('Controller: Status ist "idle" wenn kein CRDT verbunden', () => { + const ctrl = createAutosaveController({ + isConnected: () => false, + hasUnsavedChanges: () => false, + performFullSave: vi.fn(), + onStatusChange: vi.fn(), + }); + const status: AutosaveStatus = ctrl.getStatus(); + expect(status.state).toBe('offline'); + ctrl.destroy(); + }); + + it('Controller: Status ist "saved" wenn CRDT verbunden und keine ungespeicherten Aenderungen', () => { + const ctrl = createAutosaveController({ + isConnected: () => true, + hasUnsavedChanges: () => false, + performFullSave: vi.fn(), + onStatusChange: vi.fn(), + }); + expect(ctrl.getStatus().state).toBe('saved'); + ctrl.destroy(); + }); + + it('Controller: Status wechselt zu "saving" wenn Aenderungen vorhanden und Timer feuert', () => { + const fullSave = vi.fn().mockResolvedValue(undefined); + const onStatus = vi.fn(); + const ctrl = createAutosaveController({ + isConnected: () => true, + hasUnsavedChanges: () => true, + performFullSave: fullSave, + onStatusChange: onStatus, + }); + vi.advanceTimersByTime(31000); + expect(fullSave).toHaveBeenCalledTimes(1); + ctrl.destroy(); + }); + + it('Controller: fuehrt KEINEN Full-Save aus wenn keine Aenderungen', () => { + const fullSave = vi.fn(); + const ctrl = createAutosaveController({ + isConnected: () => true, + hasUnsavedChanges: () => false, + performFullSave: fullSave, + onStatusChange: vi.fn(), + }); + vi.advanceTimersByTime(31000); + expect(fullSave).not.toHaveBeenCalled(); + ctrl.destroy(); + }); + + it('Controller: fuehrt KEINEN Full-Save aus wenn CRDT offline', () => { + const fullSave = vi.fn(); + const ctrl = createAutosaveController({ + isConnected: () => false, + hasUnsavedChanges: () => true, + performFullSave: fullSave, + onStatusChange: vi.fn(), + }); + vi.advanceTimersByTime(31000); + expect(fullSave).not.toHaveBeenCalled(); + ctrl.destroy(); + }); + + it('Controller: Status-Meldung an onStatusChange bei Aenderung', () => { + const onStatus = vi.fn(); + const ctrl = createAutosaveController({ + isConnected: () => true, + hasUnsavedChanges: () => false, + performFullSave: vi.fn(), + onStatusChange: onStatus, + }); + expect(onStatus).toHaveBeenCalledWith({ state: 'saved', lastSaved: null }); + ctrl.destroy(); + }); + + it('Controller: markChanges triggert saving beim naechsten Timer-Tick', () => { + const fullSave = vi.fn().mockResolvedValue(undefined); + const ctrl = createAutosaveController({ + isConnected: () => true, + hasUnsavedChanges: () => false, + performFullSave: fullSave, + onStatusChange: vi.fn(), + }); + ctrl.markChanges(); + vi.advanceTimersByTime(31000); + expect(fullSave).toHaveBeenCalled(); + ctrl.destroy(); + }); + + it('Controller: destroy stoppt den Timer', () => { + const fullSave = vi.fn(); + const ctrl = createAutosaveController({ + isConnected: () => true, + hasUnsavedChanges: () => true, + performFullSave: fullSave, + onStatusChange: vi.fn(), + }); + ctrl.destroy(); + vi.advanceTimersByTime(60000); + expect(fullSave).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/keyboardShortcuts.test.ts b/frontend/tests/keyboardShortcuts.test.ts new file mode 100644 index 0000000..2d2754b --- /dev/null +++ b/frontend/tests/keyboardShortcuts.test.ts @@ -0,0 +1,93 @@ +/** + * Task CAD-12b - Globale Keyboard-Shortcuts fuer den Editor. + * + * ESC = aktives Tool abbrechen, Delete/Entf = Auswahl loeschen, + * Strg+A = alles auswaehlen, Strg+D = Auswahl duplizieren, + * F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createKeyboardShortcuts, type ShortcutAction } from '../src/services/keyboardShortcuts'; +describe('CAD-12b: keyboardShortcuts', () => { + let actions: Record>; + let handler: (e: KeyboardEvent) => void; + let cleanup: () => void; + + beforeEach(() => { + actions = { + escape: vi.fn(), + delete: vi.fn(), + selectAll: vi.fn(), + duplicate: vi.fn(), + toggleGrid: vi.fn(), + toggleOrtho: vi.fn(), + toggleSnap: vi.fn(), + }; + const ctrl = createKeyboardShortcuts(actions as unknown as ShortcutAction); + handler = ctrl.handler; + cleanup = ctrl.destroy; + }); + + afterEach(() => { cleanup(); }); + + const fire = (key: string, opts: { ctrl?: boolean; shift?: boolean } = {}) => { + handler({ key, ctrlKey: opts.ctrl ?? false, shiftKey: opts.shift ?? false, preventDefault: vi.fn() } as unknown as KeyboardEvent); + }; + + it('ESC ruft escape auf (Tool abbrechen)', () => { + fire('Escape'); + expect(actions.escape).toHaveBeenCalledTimes(1); + }); + + it('Delete und Entf rufen delete auf (Auswahl loeschen)', () => { + fire('Delete'); + expect(actions.delete).toHaveBeenCalledTimes(1); + fire('Backspace'); + expect(actions.delete).toHaveBeenCalledTimes(2); + }); + + it('Strg+A ruft selectAll auf', () => { + fire('a', { ctrl: true }); + expect(actions.selectAll).toHaveBeenCalledTimes(1); + }); + + it('Strg+D ruft duplicate auf', () => { + fire('d', { ctrl: true }); + expect(actions.duplicate).toHaveBeenCalledTimes(1); + }); + + it('F7 ruft toggleGrid auf', () => { + fire('F7'); + expect(actions.toggleGrid).toHaveBeenCalledTimes(1); + }); + + it('F8 ruft toggleOrtho auf', () => { + fire('F8'); + expect(actions.toggleOrtho).toHaveBeenCalledTimes(1); + }); + + it('F3 ruft toggleSnap auf', () => { + fire('F3'); + expect(actions.toggleSnap).toHaveBeenCalledTimes(1); + }); + + it('Buchstaben ohne Strg loesen KEINE Aktion aus', () => { + fire('a'); + fire('d'); + fire('x'); + expect(actions.selectAll).not.toHaveBeenCalled(); + expect(actions.duplicate).not.toHaveBeenCalled(); + }); + + it('Input-Feld-Fokus wird ignoriert (kein Shortcut wenn in Input/Textarea)', () => { + // Simuliere: Event-Target ist ein Input-Element + const inputEvent = { + key: 'a', + ctrlKey: true, + shiftKey: false, + preventDefault: vi.fn(), + target: { tagName: 'INPUT', isContentEditable: false }, + } as unknown as KeyboardEvent & { target: { tagName: string } }; + handler(inputEvent); + expect(actions.selectAll).not.toHaveBeenCalled(); + }); +});