task(CAD-12a+b): autosave service with visible status indicator + global keyboard shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3) + BOM dialog wiring fix

This commit is contained in:
Agent Zero
2026-09-16 00:51:23 +02:00
parent f7fedec0fa
commit 51d9c2f208
5 changed files with 459 additions and 0 deletions
+81
View File
@@ -18,6 +18,10 @@ import StatusBar from './components/StatusBar';
import LayoutBar from './components/LayoutBar'; import LayoutBar from './components/LayoutBar';
import MobileDrawers from './components/MobileDrawers'; import MobileDrawers from './components/MobileDrawers';
import BackgroundImport from './components/BackgroundImport'; 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 HistoryPanel from './components/HistoryPanel';
import V2HistoryPanel from './components/V2HistoryPanel'; import V2HistoryPanel from './components/V2HistoryPanel';
import GuestPanel from './components/GuestPanel'; import GuestPanel from './components/GuestPanel';
@@ -178,8 +182,78 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
// History panel // History panel
const [historyPanelOpen, setHistoryPanelOpen] = useState(false); const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
const [bomDialogOpen, setBomDialogOpen] = useState(false);
const [guestPanelOpen, setGuestPanelOpen] = 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) // Export format selector (cross-browser replacement for nwsave)
const [exportFormatOpen, setExportFormatOpen] = useState(false); const [exportFormatOpen, setExportFormatOpen] = useState(false);
@@ -1164,6 +1238,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
// ─── Background ─── // ─── Background ───
if (action === 'background-import') { setBgImportOpen(true); return; } if (action === 'background-import') { setBgImportOpen(true); return; }
if (action === 'bom') { setBomDialogOpen(true); return; }
// ─── KI actions ─── // ─── KI actions ───
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; } 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<CADEditorProps> = ({ projectId, token, onNavigateBack
onApply={handleBgApply} onApply={handleBgApply}
backgroundService={bgServiceRef.current} backgroundService={bgServiceRef.current}
/> />
{bomDialogOpen && getActiveDocument() && (
<BOMDialog
elements={getActiveDocument()!.getAllElements()}
onClose={() => setBomDialogOpen(false)}
/>
)}
{historyPanelOpen && (V2_TOOLS_ENABLED ? ( {historyPanelOpen && (V2_TOOLS_ENABLED ? (
<V2HistoryPanel <V2HistoryPanel
onClose={() => setHistoryPanelOpen(false)} onClose={() => setHistoryPanelOpen(false)}
+77
View File
@@ -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<void>;
/** 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<typeof setInterval> | 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; }
},
};
}
@@ -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
},
};
}
+130
View File
@@ -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();
});
});
+93
View File
@@ -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<string, ReturnType<typeof vi.fn>>;
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();
});
});