fix: MEDIUM issues #31-#36 — inline text editor, image/paste persist, input validation on all routes, useEffect deps, online count fix
This commit is contained in:
+63
-6
@@ -19,6 +19,7 @@ import MobileDrawers from './components/MobileDrawers';
|
||||
import BackgroundImport from './components/BackgroundImport';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import InlineTextEditor from './components/InlineTextEditor';
|
||||
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
|
||||
import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history';
|
||||
import { getCommandRegistry } from './services/commandRegistry';
|
||||
@@ -140,8 +141,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
pluginRegistry.initDefaults();
|
||||
}, []);
|
||||
|
||||
// Status bar
|
||||
const onlineCount = collab.cursors.length + 1;
|
||||
// Status bar – only count self as online when actually connected
|
||||
const onlineCount = collab.status === 'connected' ? collab.cursors.length : 0;
|
||||
|
||||
// Mobile drawers
|
||||
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||
@@ -520,13 +521,37 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
setToolState(state);
|
||||
}, []);
|
||||
|
||||
// Inline text editor state
|
||||
const [editingTextElement, setEditingTextElement] = useState<CADElement | null>(null);
|
||||
|
||||
const handleTextEdit = useCallback((el: CADElement) => {
|
||||
const text = window.prompt('Text eingeben:', '');
|
||||
if (text !== null && text.trim() !== '') {
|
||||
setEditingTextElement(el);
|
||||
}, []);
|
||||
|
||||
const handleTextEditorSubmit = useCallback((text: string) => {
|
||||
if (editingTextElement && text.trim() !== '') {
|
||||
const updatedEl = { ...editingTextElement, properties: { ...editingTextElement.properties, text } };
|
||||
setElements((prev) => prev.map((e) =>
|
||||
e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e
|
||||
e.id === editingTextElement.id ? updatedEl : e
|
||||
));
|
||||
// Push to Yjs CRDT for real-time sync
|
||||
collab.setElement(updatedEl);
|
||||
// Update in backend
|
||||
if (token) {
|
||||
setSavedStatus('Speichert…');
|
||||
updateElement(token, updatedEl.id, updatedEl).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to save text edit:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
}
|
||||
setEditingTextElement(null);
|
||||
}, [editingTextElement, collab, token]);
|
||||
|
||||
const handleTextEditorCancel = useCallback(() => {
|
||||
setEditingTextElement(null);
|
||||
}, []);
|
||||
|
||||
const handleCommandTrigger = useCallback((msg: string) => {
|
||||
@@ -816,6 +841,19 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
}));
|
||||
setElements((prev) => [...prev, ...pasted]);
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]);
|
||||
// Push pasted elements to Yjs CRDT and backend
|
||||
for (const el of pasted) {
|
||||
collab.setElement(el);
|
||||
}
|
||||
if (drawingId && token) {
|
||||
setSavedStatus('Speichert…');
|
||||
Promise.all(pasted.map(el => createElementTyped(token, drawingId, el))).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to save pasted elements:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]);
|
||||
}
|
||||
@@ -847,6 +885,18 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
};
|
||||
setElements((prev) => [...prev, imgEl]);
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]);
|
||||
// Push image element to Yjs CRDT for real-time sync
|
||||
collab.setElement(imgEl);
|
||||
// Save to backend
|
||||
if (drawingId && token) {
|
||||
setSavedStatus('Speichert…');
|
||||
createElementTyped(token, drawingId, imgEl).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to save image element:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
@@ -892,7 +942,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
||||
if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; }
|
||||
if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; }
|
||||
}, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled]);
|
||||
}, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled, collab]);
|
||||
|
||||
const handleToolChange = useCallback((tool: string) => {
|
||||
setActiveTool(tool);
|
||||
@@ -1461,6 +1511,13 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editingTextElement && (
|
||||
<InlineTextEditor
|
||||
initialText={editingTextElement.properties?.text ?? ''}
|
||||
onSubmit={handleTextEditorSubmit}
|
||||
onCancel={handleTextEditorCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* InlineTextEditor – Floating input overlay for non-blocking text editing.
|
||||
* Replaces window.prompt() for text element editing.
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface InlineTextEditorProps {
|
||||
initialText: string;
|
||||
onSubmit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const InlineTextEditor: React.FC<InlineTextEditorProps> = ({ initialText, onSubmit, onCancel }) => {
|
||||
const [text, setText] = useState(initialText);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSubmit(text);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
onSubmit(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10000,
|
||||
background: 'var(--color-bg-secondary, #2a2a2a)',
|
||||
border: '1px solid var(--color-border, #444)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
minWidth: '320px',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary, #aaa)',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
Text eingeben
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '14px',
|
||||
background: 'var(--color-bg-primary, #1a1a1a)',
|
||||
color: 'var(--color-text-primary, #fff)',
|
||||
border: '1px solid var(--color-border, #555)',
|
||||
borderRadius: '4px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
fontSize: '13px',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-secondary, #aaa)',
|
||||
border: '1px solid var(--color-border, #555)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Abbrechen (Esc)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(text)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
fontSize: '13px',
|
||||
background: 'var(--color-accent, #3498db)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Bestätigen (Enter)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineTextEditor;
|
||||
@@ -61,6 +61,12 @@ const DEFAULT_WS_URL = `wss://${window.location.host}`;
|
||||
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts;
|
||||
|
||||
// Refs for stable values that don't need to trigger effect re-runs
|
||||
const wsUrlRef = useRef(wsUrl);
|
||||
wsUrlRef.current = wsUrl;
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const yjsDocRef = useRef<YjsDocument | null>(null);
|
||||
const providerRef = useRef<WebSocketProvider | null>(null);
|
||||
const awarenessRef = useRef<AwarenessManager | null>(null);
|
||||
@@ -77,7 +83,9 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
|
||||
// Initialize Yjs document, provider, and awareness
|
||||
useEffect(() => {
|
||||
if (!enabled || !docName) return;
|
||||
const currentWsUrl = wsUrlRef.current;
|
||||
const currentEnabled = enabledRef.current;
|
||||
if (!currentEnabled || !docName) return;
|
||||
|
||||
const doc = new YjsDocument();
|
||||
yjsDocRef.current = doc;
|
||||
@@ -86,7 +94,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
awarenessRef.current = awareness;
|
||||
|
||||
const provider = new WebSocketProvider({
|
||||
url: wsUrl,
|
||||
url: currentWsUrl,
|
||||
docName,
|
||||
yjsDoc: doc,
|
||||
awareness,
|
||||
@@ -147,7 +155,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
providerRef.current = null;
|
||||
awarenessRef.current = null;
|
||||
};
|
||||
}, [docName, wsUrl, userId, userName, userColor, enabled, token]);
|
||||
}, [docName, userId, token]);
|
||||
|
||||
// Mutators
|
||||
const setElement = useCallback((el: CADElement) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* InlineTextEditor Tests
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import InlineTextEditor from '../src/components/InlineTextEditor';
|
||||
|
||||
describe('InlineTextEditor', () => {
|
||||
it('should render with initial text', () => {
|
||||
render(<InlineTextEditor initialText="Hello" onSubmit={() => {}} onCancel={() => {}} />);
|
||||
const input = screen.getByDisplayValue('Hello');
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call onSubmit with text when Enter is pressed', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<InlineTextEditor initialText="Test" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||
const input = screen.getByDisplayValue('Test');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledWith('Test');
|
||||
});
|
||||
|
||||
it('should call onCancel when Escape is pressed', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<InlineTextEditor initialText="Test" onSubmit={() => {}} onCancel={onCancel} />);
|
||||
const input = screen.getByDisplayValue('Test');
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update text when typing', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<InlineTextEditor initialText="" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'New Text' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledWith('New Text');
|
||||
});
|
||||
|
||||
it('should call onSubmit when confirm button is clicked', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<InlineTextEditor initialText="Initial" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||
const confirmBtn = screen.getByText('Bestätigen (Enter)');
|
||||
fireEvent.click(confirmBtn);
|
||||
expect(onSubmit).toHaveBeenCalledWith('Initial');
|
||||
});
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<InlineTextEditor initialText="" onSubmit={() => {}} onCancel={onCancel} />);
|
||||
const cancelBtn = screen.getByText('Abbrechen (Esc)');
|
||||
fireEvent.click(cancelBtn);
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user