From 0c3e72082ba96846924116f89f83dc0d9d6a0fa9 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 18:55:56 -0800 Subject: [PATCH 01/10] Add spacing between message name/time and always show join modal per tab - Add gap: 8px to .message-header so sender name and timestamp don't run together - Always show the name-entry modal on each new tab instead of auto-restoring from localStorage, so each tab acts as a unique participant - Modal still pre-fills the name from localStorage for convenience --- src/__tests__/SessionInit.test.tsx | 10 +++++----- src/__tests__/integration.test.tsx | 9 ++++----- src/hooks/useSessionInit.ts | 16 +++++----------- src/styles.css | 1 + 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/__tests__/SessionInit.test.tsx b/src/__tests__/SessionInit.test.tsx index 85f625d..3bb3195 100644 --- a/src/__tests__/SessionInit.test.tsx +++ b/src/__tests__/SessionInit.test.tsx @@ -53,8 +53,8 @@ describe('useSessionInit', () => { unmount(); }); - it('restores saved name and skips modal', () => { - // Pre-save a session-specific name (only session-specific names auto-restore) + it('always shows name modal even when saved name exists', () => { + // Pre-save a session-specific name const sessionId = 'preexisting1'; localStorage.setItem(`duocode_session_name_${sessionId}`, 'SavedUser'); @@ -63,9 +63,9 @@ describe('useSessionInit', () => { const { unmount } = renderHook(() => useSessionInit()); - const session = useSessionStore.getState(); - expect(session.peerName).toBe('SavedUser'); - expect(useUIStore.getState().isNameModalOpen).toBe(false); + // Each tab should prompt for name (modal pre-fills from localStorage) + expect(useSessionStore.getState().peerName).toBeNull(); + expect(useUIStore.getState().isNameModalOpen).toBe(true); unmount(); }); diff --git a/src/__tests__/integration.test.tsx b/src/__tests__/integration.test.tsx index 1e125d5..c17be9e 100644 --- a/src/__tests__/integration.test.tsx +++ b/src/__tests__/integration.test.tsx @@ -48,21 +48,20 @@ describe('Integration: App loads and creates session', () => { }); }); - it('restores saved name and skips modal', async () => { + it('always shows name modal even with saved name (each tab is unique)', async () => { const sessionId = 'test123session'; - // Only a session-specific key causes auto-restore (skips modal). - // The global key is used for pre-fill only. localStorage.setItem(`duocode_session_name_${sessionId}`, 'TestUser'); window.location.search = `?session=${sessionId}`; window.location.href = `http://localhost:3000?session=${sessionId}`; const { container } = render(); + // Modal should always show so each tab acts as a unique participant await waitFor(() => { - expect(useSessionStore.getState().peerName).toBe('TestUser'); + expect(container.querySelector('[data-testid="name-entry-modal"]')).toBeInTheDocument(); }); - expect(container.querySelector('[data-testid="name-entry-modal"]')).not.toBeInTheDocument(); + expect(useSessionStore.getState().peerName).toBeNull(); }); it('dismisses name modal on submit and stores the name', async () => { diff --git a/src/hooks/useSessionInit.ts b/src/hooks/useSessionInit.ts index 62ca3e3..da48685 100644 --- a/src/hooks/useSessionInit.ts +++ b/src/hooks/useSessionInit.ts @@ -57,20 +57,14 @@ export function useSessionInit(): void { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── Step 2: once session exists, restore or prompt for name ────── + // ── Step 2: once session exists, always prompt for name ────────── useEffect(() => { if (!sessionId || peerName) return; - // Only auto-restore if the user already confirmed their name - // for THIS specific session (i.e. they've been here before). - const sessionSpecificName = localStorage.getItem(getSessionNameKey(sessionId)); - if (sessionSpecificName) { - setPeerName(sessionSpecificName); - } else { - // New session — always show the name modal so the user can - // confirm / enter their name (pre-fill happens in the modal). - showNameModal(); - } + // Always show the name modal on a fresh tab so each tab acts as + // a unique participant. The modal pre-fills from localStorage so + // returning users can just click Join. + showNameModal(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); diff --git a/src/styles.css b/src/styles.css index d05ed56..bb476e6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1412,6 +1412,7 @@ button:disabled { display: flex; justify-content: space-between; align-items: center; + gap: 8px; margin-bottom: 6px; font-size: 12px; } From 36f129b309270225880b9ec88e048109348c59e6 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 18:58:08 -0800 Subject: [PATCH 02/10] Fix pen stroke eraser to check line segments and clean up eraser trail Pen eraser now checks point-to-segment distance between consecutive points instead of just individual point proximity. This ensures the entire pen stroke is removed when the eraser crosses any part of it. On eraser mouseUp, call redrawAll() instead of saveToBuffer() so the white eraser trail is cleanly removed from the canvas. --- .../DiagramCanvas/DiagramCanvas.tsx | 5 +-- src/services/canvas-logic.ts | 17 +++++++--- tests/unit/canvasEraser.test.ts | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/components/DiagramCanvas/DiagramCanvas.tsx b/src/components/DiagramCanvas/DiagramCanvas.tsx index cc36c62..042eb9e 100644 --- a/src/components/DiagramCanvas/DiagramCanvas.tsx +++ b/src/components/DiagramCanvas/DiagramCanvas.tsx @@ -569,7 +569,8 @@ export default function DiagramCanvas() { if (tool === 'eraser') { isDrawingRef.current = false; - saveToBuffer(); + // Redraw cleanly from remaining strokes so eraser trail disappears + redrawAll(); return; } @@ -584,7 +585,7 @@ export default function DiagramCanvas() { isDrawingRef.current = false; saveToBuffer(); - }, [getState, saveToBuffer]); + }, [getState, saveToBuffer, redrawAll]); // ── Wheel zoom ───────────────────────────────────────────────────── diff --git a/src/services/canvas-logic.ts b/src/services/canvas-logic.ts index 524ba8f..082c07d 100644 --- a/src/services/canvas-logic.ts +++ b/src/services/canvas-logic.ts @@ -161,10 +161,19 @@ export function filterStrokesAfterErase( ): Stroke[] { return strokes.filter(stroke => { if (stroke.tool === 'pen' && stroke.points) { - return !stroke.points.some(point => { - const dist = Math.sqrt((point.x - x) ** 2 + (point.y - y) ** 2); - return dist < eraseRadius; - }); + const pts = stroke.points; + // Check each segment between consecutive points + for (let i = 0; i < pts.length - 1; i++) { + if (pointToSegmentDist(x, y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) < eraseRadius) { + return false; + } + } + // Also check the first point (single-point strokes) + if (pts.length === 1) { + const dist = Math.sqrt((pts[0].x - x) ** 2 + (pts[0].y - y) ** 2); + return dist >= eraseRadius; + } + return true; } if (stroke.tool === 'text' && stroke.position) { diff --git a/tests/unit/canvasEraser.test.ts b/tests/unit/canvasEraser.test.ts index db4af69..d7c7953 100644 --- a/tests/unit/canvasEraser.test.ts +++ b/tests/unit/canvasEraser.test.ts @@ -200,10 +200,43 @@ describe('Eraser hit-testing', () => { expect(result).toHaveLength(0); }); + it('should erase when near a line segment between points', () => { + // Point (75, 75) is on the segment from (50,50) to (100,100), distance ~0 + const result = filterStrokesAfterErase([pen], 75, 75, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when close to segment but not to any point', () => { + // Pen with widely spaced points + const widePen: Stroke = { + tool: 'pen', + points: [{ x: 0, y: 0 }, { x: 200, y: 0 }], + color: '#fff', + brushSize: 2, + }; + // Point (100, 5) is 5px from the segment, within ERASE_RADIUS=10 + // but 100px from each endpoint + const result = filterStrokesAfterErase([widePen], 100, 5, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + it('should NOT erase when far from pen points', () => { const result = filterStrokesAfterErase([pen], 300, 300, ERASE_RADIUS); expect(result).toHaveLength(1); }); + + it('should handle single-point pen stroke', () => { + const singlePoint: Stroke = { + tool: 'pen', + points: [{ x: 50, y: 50 }], + color: '#fff', + brushSize: 2, + }; + const hit = filterStrokesAfterErase([singlePoint], 52, 52, ERASE_RADIUS); + expect(hit).toHaveLength(0); + const miss = filterStrokesAfterErase([singlePoint], 300, 300, ERASE_RADIUS); + expect(miss).toHaveLength(1); + }); }); describe('Multiple strokes', () => { From 508ca7d837aa26fbd91fbf27586450527af10632 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 19:05:44 -0800 Subject: [PATCH 03/10] Add borderless shape text editing with selection highlight and tests Shape text editing: double-clicking a rect/circle now shows a borderless transparent textarea with the shape's selection dashed border visible instead of the text input border. Added shapeEditing prop to TextInputOverlay, shape-editing CSS class, and selection highlight management in DiagramCanvas. Includes 13 new tests for shape text editing and pen eraser segment hit-testing. --- .../DiagramCanvas/DiagramCanvas.tsx | 9 ++- .../DiagramCanvas/TextInputOverlay.tsx | 5 +- src/styles.css | 10 +++ tests/unit/sessionPerTab.test.ts | 75 +++++++++++++++++++ tests/unit/shapeTextEditing.test.tsx | 49 ++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 tests/unit/sessionPerTab.test.ts create mode 100644 tests/unit/shapeTextEditing.test.tsx diff --git a/src/components/DiagramCanvas/DiagramCanvas.tsx b/src/components/DiagramCanvas/DiagramCanvas.tsx index 042eb9e..04d8adb 100644 --- a/src/components/DiagramCanvas/DiagramCanvas.tsx +++ b/src/components/DiagramCanvas/DiagramCanvas.tsx @@ -302,6 +302,7 @@ export default function DiagramCanvas() { (text: string) => { if (!text.trim()) { textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }; + selectedIndexRef.current = null; forceOverlayUpdate(); return; } @@ -326,6 +327,7 @@ export default function DiagramCanvas() { getState().addStroke(stroke); } textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }; + selectedIndexRef.current = null; forceOverlayUpdate(); }, [getState, forceOverlayUpdate], @@ -333,6 +335,7 @@ export default function DiagramCanvas() { const handleTextDismiss = useCallback(() => { textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }; + selectedIndexRef.current = null; forceOverlayUpdate(); }, [forceOverlayUpdate]); @@ -768,12 +771,15 @@ export default function DiagramCanvas() { const shape = strokes[shapeIndex]; const center = getShapeCenter(shape); textOverlayRef.current = { visible: true, x: center.x, y: center.y, shapeIndex, editIndex: null, initialText: shape.text || '' }; + // Show selection highlight on the shape being edited + selectedIndexRef.current = shapeIndex; + redrawAll(); } else { textOverlayRef.current = { visible: true, x: pos.x, y: pos.y, shapeIndex: null, editIndex: null, initialText: '' }; } forceOverlayUpdate(); }, - [getMousePos, getState, forceOverlayUpdate], + [getMousePos, getState, forceOverlayUpdate, redrawAll], ); return ( @@ -800,6 +806,7 @@ export default function DiagramCanvas() { onCommit={handleTextCommit} onDismiss={handleTextDismiss} initialText={textOverlayRef.current.initialText} + shapeEditing={textOverlayRef.current.shapeIndex !== null} /> )} diff --git a/src/components/DiagramCanvas/TextInputOverlay.tsx b/src/components/DiagramCanvas/TextInputOverlay.tsx index f39e82d..2e14926 100644 --- a/src/components/DiagramCanvas/TextInputOverlay.tsx +++ b/src/components/DiagramCanvas/TextInputOverlay.tsx @@ -5,9 +5,10 @@ interface TextInputOverlayProps { onCommit: (text: string) => void; onDismiss: () => void; initialText?: string; + shapeEditing?: boolean; } -export default function TextInputOverlay({ position, onCommit, onDismiss, initialText }: TextInputOverlayProps) { +export default function TextInputOverlay({ position, onCommit, onDismiss, initialText, shapeEditing }: TextInputOverlayProps) { const inputRef = useRef(null); const mountedAtRef = useRef(Date.now()); const committedRef = useRef(false); @@ -64,7 +65,7 @@ export default function TextInputOverlay({ position, onCommit, onDismiss, initia return (