From befc38337a3b2a707aa95584dd83e34597e7bd67 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 09:19:25 -0800 Subject: [PATCH 1/6] Fix text disappearing when clicking outside text input on canvas When a user typed text in the drawing pane's text input and clicked elsewhere on the canvas, the text was lost. The root cause: handleMouseDown changed the overlay key (via forceOverlayUpdate), unmounting the old TextInputOverlay before its blur handler could read the input value. Fix: - In handleMouseDown (text tool), read the DOM input value and commit it before creating a new overlay - Move handleTextCommit/handleTextDismiss before handleMouseDown to avoid temporal dead zone issues in the dependency array - Add committedRef guard in TextInputOverlay to prevent double-commit when Enter/Escape is followed by blur on unmount --- .../DiagramCanvas/DiagramCanvas.tsx | 86 ++++---- .../DiagramCanvas/TextInputOverlay.tsx | 6 + tests/unit/canvasTextCommit.test.tsx | 199 ++++++++++++++++++ 3 files changed, 253 insertions(+), 38 deletions(-) create mode 100644 tests/unit/canvasTextCommit.test.tsx diff --git a/src/components/DiagramCanvas/DiagramCanvas.tsx b/src/components/DiagramCanvas/DiagramCanvas.tsx index c64ffd0..3a0146e 100644 --- a/src/components/DiagramCanvas/DiagramCanvas.tsx +++ b/src/components/DiagramCanvas/DiagramCanvas.tsx @@ -245,6 +245,43 @@ export default function DiagramCanvas() { const [overlayKey, setOverlayKey] = useState(0); const forceOverlayUpdate = useCallback(() => setOverlayKey((k) => k + 1), []); + // ── Text overlay handlers (before mouse handlers that reference them) ── + + const handleTextCommit = useCallback( + (text: string) => { + if (!text.trim()) { + textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; + forceOverlayUpdate(); + return; + } + + const { shapeIndex } = textOverlayRef.current; + if (shapeIndex !== null) { + // Add text to existing shape + getState().updateStrokeAt(shapeIndex, { text }); + } else { + // Create standalone text stroke + const { strokeColor: color } = getState(); + const stroke: Stroke = { + tool: 'text', + text, + color, + position: { x: textOverlayRef.current.x, y: textOverlayRef.current.y }, + fontSize: DEFAULT_FONT_SIZE, + }; + getState().addStroke(stroke); + } + textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; + forceOverlayUpdate(); + }, + [getState, forceOverlayUpdate], + ); + + const handleTextDismiss = useCallback(() => { + textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; + forceOverlayUpdate(); + }, [forceOverlayUpdate]); + // ── Mouse event handlers ─────────────────────────────────────────── const handleMouseDown = useCallback( @@ -269,6 +306,16 @@ export default function DiagramCanvas() { if (tool === 'text') { isDrawingRef.current = false; + + // If a text overlay is already visible, commit its value before opening a new one. + // We must read the DOM directly because the React component will unmount + // (key change) before its blur handler can capture the value. + if (textOverlayRef.current.visible) { + const input = document.getElementById('canvasTextInput') as HTMLInputElement | null; + const value = input?.value ?? ''; + handleTextCommit(value); + } + textOverlayRef.current = { visible: true, x: pos.x, y: pos.y, shapeIndex: null }; forceOverlayUpdate(); return; @@ -310,7 +357,7 @@ export default function DiagramCanvas() { }; } }, - [getState, getMousePos, getBufferPos, getCanvasBackground, forceOverlayUpdate], + [getState, getMousePos, getBufferPos, getCanvasBackground, forceOverlayUpdate, handleTextCommit], ); const handleMouseMove = useCallback( @@ -537,43 +584,6 @@ export default function DiagramCanvas() { return () => canvas.removeEventListener('wheel', handleWheel); }, [handleWheel]); - // ── Text overlay handlers ────────────────────────────────────────── - - const handleTextCommit = useCallback( - (text: string) => { - if (!text.trim()) { - textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; - forceOverlayUpdate(); - return; - } - - const { shapeIndex } = textOverlayRef.current; - if (shapeIndex !== null) { - // Add text to existing shape - getState().updateStrokeAt(shapeIndex, { text }); - } else { - // Create standalone text stroke - const { strokeColor: color } = getState(); - const stroke: Stroke = { - tool: 'text', - text, - color, - position: { x: textOverlayRef.current.x, y: textOverlayRef.current.y }, - fontSize: DEFAULT_FONT_SIZE, - }; - getState().addStroke(stroke); - } - textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; - forceOverlayUpdate(); - }, - [getState, forceOverlayUpdate], - ); - - const handleTextDismiss = useCallback(() => { - textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; - forceOverlayUpdate(); - }, [forceOverlayUpdate]); - // Convert logical position to screen position for overlay const getOverlayScreenPos = useCallback(() => { const canvas = canvasRef.current; diff --git a/src/components/DiagramCanvas/TextInputOverlay.tsx b/src/components/DiagramCanvas/TextInputOverlay.tsx index c633f13..312f6a4 100644 --- a/src/components/DiagramCanvas/TextInputOverlay.tsx +++ b/src/components/DiagramCanvas/TextInputOverlay.tsx @@ -9,6 +9,7 @@ interface TextInputOverlayProps { export default function TextInputOverlay({ position, onCommit, onDismiss }: TextInputOverlayProps) { const inputRef = useRef(null); const mountedAtRef = useRef(Date.now()); + const committedRef = useRef(false); useEffect(() => { inputRef.current?.focus(); @@ -18,8 +19,10 @@ export default function TextInputOverlay({ position, onCommit, onDismiss }: Text (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); + committedRef.current = true; onCommit(inputRef.current?.value ?? ''); } else if (e.key === 'Escape') { + committedRef.current = true; onDismiss(); } }, @@ -27,6 +30,9 @@ export default function TextInputOverlay({ position, onCommit, onDismiss }: Text ); const handleBlur = useCallback(() => { + // Already committed via Enter/Escape — skip to avoid double-commit + if (committedRef.current) return; + // Re-focus if blur happens within 300ms of mount — prevents the mouseup // from the initial canvas click from stealing focus if (Date.now() - mountedAtRef.current < 300) { diff --git a/tests/unit/canvasTextCommit.test.tsx b/tests/unit/canvasTextCommit.test.tsx new file mode 100644 index 0000000..6d5b9ff --- /dev/null +++ b/tests/unit/canvasTextCommit.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { useCanvasStore } from '../../src/stores/canvasStore'; +import TextInputOverlay from '../../src/components/DiagramCanvas/TextInputOverlay'; + +/** + * Tests for canvas text commit behavior. + * + * Bug: When clicking outside the text input (on the canvas), the text disappears + * instead of being committed. This happens because handleMouseDown creates a new + * overlay (changing the React key), which unmounts the old TextInputOverlay before + * its blur handler can read the input value. + * + * These tests verify: + * 1. Text commit via Enter key works correctly + * 2. Text commit via blur (click outside) works correctly + * 3. No double-commit when Enter is followed by blur on unmount + */ + +describe('Canvas text commit', () => { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + describe('TextInputOverlay commit behavior', () => { + it('should call onCommit with text value when Enter is pressed', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'Enter Text' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onCommit).toHaveBeenCalledWith('Enter Text'); + expect(onCommit).toHaveBeenCalledTimes(1); + }); + + it('should call onCommit with text value on blur after grace period', () => { + const now = Date.now(); + const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now); + + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'Blur Text' } }); + + // Advance past the 300ms grace period + dateSpy.mockReturnValue(now + 400); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith('Blur Text'); + expect(onCommit).toHaveBeenCalledTimes(1); + + dateSpy.mockRestore(); + }); + + it('should not double-commit when Enter is followed by unmount blur', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + const { unmount } = render( + , + ); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'Test Text' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('Test Text'); + + // Simulate unmount (which triggers blur in real browser) + unmount(); + + // onCommit should still only have been called once + expect(onCommit).toHaveBeenCalledTimes(1); + }); + }); + + describe('Canvas text stroke persistence', () => { + it('should persist text stroke in store when committed via Enter', () => { + const store = useCanvasStore.getState(); + expect(store.drawingStrokes).toHaveLength(0); + + // Simulate what handleTextCommit does when Enter is pressed + const stroke = { + tool: 'text' as const, + text: 'Enter Text', + color: '#ffffff', + position: { x: 200, y: 300 }, + fontSize: 16, + }; + store.addStroke(stroke); + + const updated = useCanvasStore.getState(); + expect(updated.drawingStrokes).toHaveLength(1); + expect(updated.drawingStrokes[0]).toMatchObject({ + tool: 'text', + text: 'Enter Text', + position: { x: 200, y: 300 }, + }); + }); + + it('should persist text stroke in store when committed via blur (click outside on canvas)', () => { + // This test simulates the real browser scenario: + // 1. User clicks canvas with text tool → text input appears at position (200, 300) + // 2. User types "Blur Text" + // 3. User clicks elsewhere on canvas → handleMouseDown reads DOM input value + // and calls handleTextCommit BEFORE remounting the overlay + // + // The fix: DiagramCanvas.handleMouseDown now reads the DOM input value + // and commits it before changing the overlay key (which causes remount). + + const committedTexts: string[] = []; + const handleTextCommit = (text: string) => { + if (!text.trim()) return; + committedTexts.push(text); + useCanvasStore.getState().addStroke({ + tool: 'text', + text, + color: '#ffffff', + position: { x: 200, y: 300 }, + fontSize: 16, + }); + }; + + const now = Date.now(); + const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now); + + // Step 1: Render text input overlay (simulating first canvas click) + const { rerender, unmount } = render( + {}} + />, + ); + + // Step 2: User types text + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'Blur Text' } }); + + // Advance past grace period + dateSpy.mockReturnValue(now + 400); + + // Step 3: Simulate what DiagramCanvas.handleMouseDown does on click-outside: + // It reads the DOM input value BEFORE remounting the overlay. + // This is the fix — the parent captures the value before the child unmounts. + const domInput = document.getElementById('canvasTextInput') as HTMLInputElement | null; + const value = domInput?.value ?? ''; + handleTextCommit(value); + + // Then the overlay is remounted with a new key (new position) + rerender( + {}} + />, + ); + + // Check: was the "Blur Text" committed to the store? + const storeState = useCanvasStore.getState(); + expect(committedTexts).toContain('Blur Text'); + expect(storeState.drawingStrokes).toHaveLength(1); + expect(storeState.drawingStrokes[0]).toMatchObject({ + tool: 'text', + text: 'Blur Text', + position: { x: 200, y: 300 }, + }); + + dateSpy.mockRestore(); + unmount(); + }); + }); +}); From 07ea26d50b40da5554c93e3aaae5847d2a3a86bb Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 10:01:00 -0800 Subject: [PATCH 2/6] Canvas text & drawing UX: multi-line input, click-to-edit, selection tool - Multi-line text: textarea replaces input, Enter=newline, Ctrl/Cmd+Enter=commit - Text positioning: overlay offset accounts for padding+border alignment - Click existing text to edit: findTextAtPosition hit-testing, in-place update - Double-click shapes: pre-fills overlay with existing shape text, uses shape center - Selection tool: select/move any element (pen, line, rect, circle, text) with visual dashed-border feedback and drag-to-move - WebRTC sync fix: strokeVersion counter in store triggers full sync on in-place updates (updateStrokeAt) that don't change array length - CLAUDE.md: add commit rules (no co-author lines) - 6 new test files (50+ tests), updated existing tests for Ctrl+Enter --- CLAUDE.md | 4 + .../DiagramCanvas/CanvasToolbar.tsx | 10 + .../DiagramCanvas/DiagramCanvas.tsx | 157 +++++++++++-- .../DiagramCanvas/TextInputOverlay.tsx | 38 ++- src/hooks/useCanvasSync.ts | 18 ++ src/services/canvas-logic.ts | 172 ++++++++++++++ src/stores/canvasStore.ts | 4 +- src/styles.css | 8 +- tests/e2e/text-tool.spec.ts | 14 +- tests/unit/TextInputOverlay.test.tsx | 4 +- tests/unit/canvasSelect.test.tsx | 216 ++++++++++++++++++ tests/unit/canvasShapeText.test.tsx | 133 +++++++++++ tests/unit/canvasSync.test.ts | 91 ++++++++ tests/unit/canvasTextCommit.test.tsx | 12 +- tests/unit/canvasTextEdit.test.tsx | 150 ++++++++++++ tests/unit/canvasTextMultiline.test.tsx | 158 +++++++++++++ tests/unit/canvasTextPosition.test.ts | 92 ++++++++ 17 files changed, 1234 insertions(+), 47 deletions(-) create mode 100644 tests/unit/canvasSelect.test.tsx create mode 100644 tests/unit/canvasShapeText.test.tsx create mode 100644 tests/unit/canvasSync.test.ts create mode 100644 tests/unit/canvasTextEdit.test.tsx create mode 100644 tests/unit/canvasTextMultiline.test.tsx create mode 100644 tests/unit/canvasTextPosition.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7a6a837..8af25e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,10 @@ GitHub Actions (`.github/workflows/ci.yml`): pushes and PRs to `main` run unit t See `docs/DEPLOYMENT.md` for full deployment instructions. The frontend builds to `dist/` and can be served by any static file server. The signaling server runs as a Node.js process. Services can be managed with systemd or PM2. Health check: `curl http://localhost:3001/health`. +## Commit Rules + +- Do NOT include `Co-Authored-By` lines in commit messages. Commits should only show the git-configured user as author. + ## Environment Variables - `PORT` (server, default 3001) — signaling server port diff --git a/src/components/DiagramCanvas/CanvasToolbar.tsx b/src/components/DiagramCanvas/CanvasToolbar.tsx index f67390e..8826a9b 100644 --- a/src/components/DiagramCanvas/CanvasToolbar.tsx +++ b/src/components/DiagramCanvas/CanvasToolbar.tsx @@ -9,6 +9,16 @@ interface ToolDefinition { } const TOOLS: ToolDefinition[] = [ + { + id: 'select', + title: 'Select / Move', + icon: ( + + + + + ), + }, { id: 'pen', title: 'Pen', diff --git a/src/components/DiagramCanvas/DiagramCanvas.tsx b/src/components/DiagramCanvas/DiagramCanvas.tsx index 3a0146e..cc36c62 100644 --- a/src/components/DiagramCanvas/DiagramCanvas.tsx +++ b/src/components/DiagramCanvas/DiagramCanvas.tsx @@ -11,6 +11,11 @@ import { applyCanvasTransform, reconcileCoordinates, filterStrokesAfterErase, + getShapeCenter, + findTextAtPosition, + findStrokeAtPosition, + getStrokeBounds, + translateStroke, DEFAULT_FONT_SIZE, MIN_SCALE, MAX_SCALE, @@ -24,6 +29,8 @@ interface TextOverlayState { x: number; y: number; shapeIndex: number | null; + editIndex: number | null; + initialText: string; } interface MouseLikeEvent { @@ -48,8 +55,13 @@ export default function DiagramCanvas() { const pinchScaleRef = useRef(1); const lastPanPointRef = useRef({ x: 0, y: 0 }); + // Selection state refs + const selectedIndexRef = useRef(null); + const dragStartRef = useRef({ x: 0, y: 0 }); + const isDraggingRef = useRef(false); + // Text overlay state - const textOverlayRef = useRef({ visible: false, x: 0, y: 0, shapeIndex: null }); + const textOverlayRef = useRef({ visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }); // Store selectors — only subscribe to values that trigger re-renders const currentTool = useCanvasStore((s) => s.currentTool); @@ -122,10 +134,15 @@ export default function DiagramCanvas() { (ctx: CanvasRenderingContext2D, strokes: Stroke[]) => { strokes.forEach((stroke) => { if (stroke.tool === 'text' && stroke.text && stroke.position) { - ctx.font = `${stroke.fontSize || DEFAULT_FONT_SIZE}px sans-serif`; + const fontSize = stroke.fontSize || DEFAULT_FONT_SIZE; + ctx.font = `${fontSize}px sans-serif`; ctx.fillStyle = stroke.color || '#000'; ctx.textBaseline = 'top'; - ctx.fillText(stroke.text, stroke.position.x, stroke.position.y); + const lines = stroke.text.split('\n'); + const lineHeight = fontSize * 1.3; + lines.forEach((line, i) => { + ctx.fillText(line, stroke.position!.x, stroke.position!.y + i * lineHeight); + }); } else { ctx.beginPath(); ctx.strokeStyle = stroke.color || '#000'; @@ -176,6 +193,29 @@ export default function DiagramCanvas() { [], ); + const renderSelection = useCallback( + (ctx: CanvasRenderingContext2D, strokes: Stroke[]) => { + const idx = selectedIndexRef.current; + if (idx === null || idx < 0 || idx >= strokes.length) return; + + const bounds = getStrokeBounds(strokes[idx]); + const pad = 6; + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = '#007bff'; + ctx.lineWidth = 1.5; + ctx.strokeRect( + bounds.minX - pad, + bounds.minY - pad, + bounds.maxX - bounds.minX + pad * 2, + bounds.maxY - bounds.minY + pad * 2, + ); + ctx.setLineDash([]); + ctx.restore(); + }, + [], + ); + const saveToBuffer = useCallback(() => { const canvas = canvasRef.current; const buffer = bufferRef.current; @@ -212,7 +252,10 @@ export default function DiagramCanvas() { if (buffer && strokes?.length > 0) { ctx.drawImage(buffer, 0, 0); } - }, [getState, getCanvasBackground]); + + // Draw selection highlight on top (not baked into buffer) + renderSelection(ctx, strokes); + }, [getState, getCanvasBackground, renderSelection]); const redrawAll = useCallback(() => { const canvas = canvasRef.current; @@ -240,6 +283,14 @@ export default function DiagramCanvas() { return () => cancelAnimationFrame(id); }, [drawingStrokes, theme, redrawAll]); + // Clear selection when switching away from select tool + useEffect(() => { + if (currentTool !== 'select') { + selectedIndexRef.current = null; + isDraggingRef.current = false; + } + }, [currentTool]); + // ── Text overlay state (must be before mouse handlers that reference it) ── const [overlayKey, setOverlayKey] = useState(0); @@ -250,15 +301,18 @@ export default function DiagramCanvas() { const handleTextCommit = useCallback( (text: string) => { if (!text.trim()) { - textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; + textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }; forceOverlayUpdate(); return; } - const { shapeIndex } = textOverlayRef.current; + const { shapeIndex, editIndex } = textOverlayRef.current; if (shapeIndex !== null) { - // Add text to existing shape + // Add/update text on existing shape getState().updateStrokeAt(shapeIndex, { text }); + } else if (editIndex !== null) { + // Edit existing text stroke in-place + getState().updateStrokeAt(editIndex, { text }); } else { // Create standalone text stroke const { strokeColor: color } = getState(); @@ -271,14 +325,14 @@ export default function DiagramCanvas() { }; getState().addStroke(stroke); } - textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; + textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }; forceOverlayUpdate(); }, [getState, forceOverlayUpdate], ); const handleTextDismiss = useCallback(() => { - textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null }; + textOverlayRef.current = { visible: false, x: 0, y: 0, shapeIndex: null, editIndex: null, initialText: '' }; forceOverlayUpdate(); }, [forceOverlayUpdate]); @@ -295,6 +349,22 @@ export default function DiagramCanvas() { const pos = getMousePos(e); startPosRef.current = pos; + if (tool === 'select') { + isDrawingRef.current = false; + const strokes = getState().drawingStrokes; + const hitIdx = findStrokeAtPosition(strokes, pos.x, pos.y); + if (hitIdx !== null) { + selectedIndexRef.current = hitIdx; + dragStartRef.current = pos; + isDraggingRef.current = true; + } else { + selectedIndexRef.current = null; + isDraggingRef.current = false; + } + redrawViewport(); + return; + } + if (tool === 'pan') { const bufPos = getBufferPos(e); panStartRef.current = bufPos; @@ -308,15 +378,28 @@ export default function DiagramCanvas() { isDrawingRef.current = false; // If a text overlay is already visible, commit its value before opening a new one. - // We must read the DOM directly because the React component will unmount - // (key change) before its blur handler can capture the value. if (textOverlayRef.current.visible) { - const input = document.getElementById('canvasTextInput') as HTMLInputElement | null; + const input = document.getElementById('canvasTextInput') as HTMLTextAreaElement | null; const value = input?.value ?? ''; handleTextCommit(value); } - textOverlayRef.current = { visible: true, x: pos.x, y: pos.y, shapeIndex: null }; + // Check if clicking on existing text stroke to edit it + const strokes = getState().drawingStrokes; + const textIndex = findTextAtPosition(strokes, pos.x, pos.y); + if (textIndex !== null) { + const stroke = strokes[textIndex]; + textOverlayRef.current = { + visible: true, + x: stroke.position!.x, + y: stroke.position!.y, + shapeIndex: null, + editIndex: textIndex, + initialText: stroke.text || '', + }; + } else { + textOverlayRef.current = { visible: true, x: pos.x, y: pos.y, shapeIndex: null, editIndex: null, initialText: '' }; + } forceOverlayUpdate(); return; } @@ -357,11 +440,28 @@ export default function DiagramCanvas() { }; } }, - [getState, getMousePos, getBufferPos, getCanvasBackground, forceOverlayUpdate, handleTextCommit], + [getState, getMousePos, getBufferPos, getCanvasBackground, forceOverlayUpdate, handleTextCommit, redrawViewport], ); const handleMouseMove = useCallback( (e: MouseLikeEvent) => { + // Handle select tool drag (isDragging is separate from isDrawing) + if (isDraggingRef.current && selectedIndexRef.current !== null) { + const pos = getMousePos(e); + const dx = pos.x - dragStartRef.current.x; + const dy = pos.y - dragStartRef.current.y; + if (dx === 0 && dy === 0) return; + + const strokes = getState().drawingStrokes; + const idx = selectedIndexRef.current; + if (idx >= 0 && idx < strokes.length) { + const moved = translateStroke(strokes[idx], dx, dy); + getState().updateStrokeAt(idx, moved); + dragStartRef.current = pos; + } + return; + } + if (!isDrawingRef.current) return; const ctx = ctxRef.current; if (!ctx) return; @@ -442,6 +542,13 @@ export default function DiagramCanvas() { ); const handleMouseUp = useCallback(() => { + // Handle select tool drag end + if (isDraggingRef.current) { + isDraggingRef.current = false; + // Selection stays active so user can see what they moved + return; + } + if (!isDrawingRef.current) return; const ctx = ctxRef.current; const { currentTool: tool } = getState(); @@ -584,7 +691,12 @@ export default function DiagramCanvas() { return () => canvas.removeEventListener('wheel', handleWheel); }, [handleWheel]); - // Convert logical position to screen position for overlay + // Convert logical position to screen position for overlay. + // Subtract the overlay's CSS padding+border so the text inside the input + // aligns exactly with where ctx.fillText will render on the canvas. + const OVERLAY_OFFSET_LEFT = 10; // padding-left(8) + border(2) + const OVERLAY_OFFSET_TOP = 6; // padding-top(4) + border(2) + const getOverlayScreenPos = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return { left: 0, top: 0 }; @@ -598,8 +710,8 @@ export default function DiagramCanvas() { const cssScaleY = rect.height / canvas.height; return { - left: bufferX * cssScaleX, - top: bufferY * cssScaleY, + left: bufferX * cssScaleX - OVERLAY_OFFSET_LEFT, + top: bufferY * cssScaleY - OVERLAY_OFFSET_TOP, }; }, [getState]); @@ -607,6 +719,8 @@ export default function DiagramCanvas() { const getCursorStyle = useCallback((): string => { switch (currentTool) { + case 'select': + return 'default'; case 'pan': return 'grab'; case 'eraser': @@ -649,7 +763,13 @@ export default function DiagramCanvas() { } } } - textOverlayRef.current = { visible: true, x: pos.x, y: pos.y, shapeIndex }; + if (shapeIndex !== null) { + const shape = strokes[shapeIndex]; + const center = getShapeCenter(shape); + textOverlayRef.current = { visible: true, x: center.x, y: center.y, shapeIndex, editIndex: null, initialText: shape.text || '' }; + } else { + textOverlayRef.current = { visible: true, x: pos.x, y: pos.y, shapeIndex: null, editIndex: null, initialText: '' }; + } forceOverlayUpdate(); }, [getMousePos, getState, forceOverlayUpdate], @@ -678,6 +798,7 @@ export default function DiagramCanvas() { position={getOverlayScreenPos()} onCommit={handleTextCommit} onDismiss={handleTextDismiss} + initialText={textOverlayRef.current.initialText} /> )} diff --git a/src/components/DiagramCanvas/TextInputOverlay.tsx b/src/components/DiagramCanvas/TextInputOverlay.tsx index 312f6a4..f39e82d 100644 --- a/src/components/DiagramCanvas/TextInputOverlay.tsx +++ b/src/components/DiagramCanvas/TextInputOverlay.tsx @@ -4,20 +4,29 @@ interface TextInputOverlayProps { position: { left: number; top: number }; onCommit: (text: string) => void; onDismiss: () => void; + initialText?: string; } -export default function TextInputOverlay({ position, onCommit, onDismiss }: TextInputOverlayProps) { - const inputRef = useRef(null); +export default function TextInputOverlay({ position, onCommit, onDismiss, initialText }: TextInputOverlayProps) { + const inputRef = useRef(null); const mountedAtRef = useRef(Date.now()); const committedRef = useRef(false); useEffect(() => { - inputRef.current?.focus(); - }, []); + const el = inputRef.current; + if (!el) return; + el.focus(); + if (initialText) { + el.value = initialText; + // Place cursor at end + el.selectionStart = el.selectionEnd = initialText.length; + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + // Ctrl+Enter / Cmd+Enter → commit e.preventDefault(); committedRef.current = true; onCommit(inputRef.current?.value ?? ''); @@ -25,16 +34,14 @@ export default function TextInputOverlay({ position, onCommit, onDismiss }: Text committedRef.current = true; onDismiss(); } + // Plain Enter = default textarea newline behavior (no preventDefault) }, [onCommit, onDismiss], ); const handleBlur = useCallback(() => { - // Already committed via Enter/Escape — skip to avoid double-commit if (committedRef.current) return; - // Re-focus if blur happens within 300ms of mount — prevents the mouseup - // from the initial canvas click from stealing focus if (Date.now() - mountedAtRef.current < 300) { requestAnimationFrame(() => inputRef.current?.focus()); return; @@ -47,6 +54,14 @@ export default function TextInputOverlay({ position, onCommit, onDismiss }: Text } }, [onCommit, onDismiss]); + const handleInput = useCallback(() => { + // Auto-resize textarea height to fit content + const el = inputRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, []); + return (
-
); diff --git a/src/hooks/useCanvasSync.ts b/src/hooks/useCanvasSync.ts index fe133c3..90b70e2 100644 --- a/src/hooks/useCanvasSync.ts +++ b/src/hooks/useCanvasSync.ts @@ -31,9 +31,11 @@ export function useCanvasSync({ sendMessage }: UseCanvasSyncOptions = {}): UseCa const clear = useCanvasStore((s) => s.clear); const updateRemoteDrawer = useCanvasStore((s) => s.updateRemoteDrawer); const drawingStrokes = useCanvasStore((s) => s.drawingStrokes); + const strokeVersion = useCanvasStore((s) => s.strokeVersion); const isRemoteUpdateRef = useRef(false); const previousStrokesLenRef = useRef(0); + const previousVersionRef = useRef(0); // Auto-send new strokes when added locally useEffect(() => { @@ -66,6 +68,22 @@ export function useCanvasSync({ sendMessage }: UseCanvasSyncOptions = {}): UseCa previousStrokesLenRef.current = currLen; }, [drawingStrokes, sendMessage]); + // Detect in-place stroke updates (same length, version bumped) + useEffect(() => { + if (isRemoteUpdateRef.current) { + previousVersionRef.current = strokeVersion; + return; + } + + if (strokeVersion > previousVersionRef.current && sendMessage) { + sendMessage({ + type: 'canvas-sync', + strokes: drawingStrokes, + }); + } + previousVersionRef.current = strokeVersion; + }, [strokeVersion, drawingStrokes, sendMessage]); + const sendStroke = useCallback((stroke: Stroke) => { if (sendMessage) { sendMessage({ diff --git a/src/services/canvas-logic.ts b/src/services/canvas-logic.ts index 7f9df19..10ee25e 100644 --- a/src/services/canvas-logic.ts +++ b/src/services/canvas-logic.ts @@ -208,6 +208,178 @@ export function getShapeCenter(stroke: Stroke): Point { return stroke.position || { x: 0, y: 0 }; } +// ── Universal Hit-Testing & Selection ──────────────────────────────────────── + +export interface StrokeBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * Returns the bounding box for any stroke type. + */ +export function getStrokeBounds(stroke: Stroke): StrokeBounds { + if (stroke.tool === 'pen' && stroke.points && stroke.points.length > 0) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of stroke.points) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + return { minX, minY, maxX, maxY }; + } + + if (stroke.tool === 'circle' && stroke.start && stroke.end) { + const radius = Math.sqrt( + (stroke.end.x - stroke.start.x) ** 2 + (stroke.end.y - stroke.start.y) ** 2 + ); + return { + minX: stroke.start.x - radius, + minY: stroke.start.y - radius, + maxX: stroke.start.x + radius, + maxY: stroke.start.y + radius, + }; + } + + if (stroke.tool === 'text' && stroke.position) { + const fontSize = stroke.fontSize || DEFAULT_FONT_SIZE; + const lines = (stroke.text || '').split('\n'); + const lineHeight = fontSize * 1.3; + const maxLineLen = Math.max(...lines.map(l => l.length), 1); + const approxWidth = Math.max(maxLineLen * fontSize * 0.6, 20); + const approxHeight = lines.length * lineHeight; + return { + minX: stroke.position.x, + minY: stroke.position.y, + maxX: stroke.position.x + approxWidth, + maxY: stroke.position.y + approxHeight, + }; + } + + // line, rectangle, or any start/end shape + if (stroke.start && stroke.end) { + return { + minX: Math.min(stroke.start.x, stroke.end.x), + minY: Math.min(stroke.start.y, stroke.end.y), + maxX: Math.max(stroke.start.x, stroke.end.x), + maxY: Math.max(stroke.start.y, stroke.end.y), + }; + } + + return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; +} + +/** + * Universal hit-test for all stroke types. + * Returns the index of the topmost stroke at (x,y), or null. + */ +export function findStrokeAtPosition(strokes: Stroke[], x: number, y: number): number | null { + const HIT_TOLERANCE = 8; + + for (let i = strokes.length - 1; i >= 0; i--) { + const stroke = strokes[i]; + + if (stroke.tool === 'rectangle' && stroke.start && stroke.end) { + const minX = Math.min(stroke.start.x, stroke.end.x); + const maxX = Math.max(stroke.start.x, stroke.end.x); + const minY = Math.min(stroke.start.y, stroke.end.y); + const maxY = Math.max(stroke.start.y, stroke.end.y); + if (x >= minX && x <= maxX && y >= minY && y <= maxY) return i; + } else if (stroke.tool === 'circle' && stroke.start && stroke.end) { + const radius = Math.sqrt( + (stroke.end.x - stroke.start.x) ** 2 + (stroke.end.y - stroke.start.y) ** 2 + ); + const dist = Math.sqrt((x - stroke.start.x) ** 2 + (y - stroke.start.y) ** 2); + if (dist <= radius) return i; + } else if (stroke.tool === 'text' && stroke.text && stroke.position) { + const bounds = getStrokeBounds(stroke); + if (x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY) return i; + } else if (stroke.tool === 'pen' && stroke.points && stroke.points.length > 0) { + for (const p of stroke.points) { + if (Math.abs(x - p.x) <= HIT_TOLERANCE && Math.abs(y - p.y) <= HIT_TOLERANCE) return i; + } + } else if (stroke.tool === 'line' && stroke.start && stroke.end) { + // Point-to-line-segment distance + const dx = stroke.end.x - stroke.start.x; + const dy = stroke.end.y - stroke.start.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + if (Math.abs(x - stroke.start.x) <= HIT_TOLERANCE && Math.abs(y - stroke.start.y) <= HIT_TOLERANCE) return i; + } else { + let t = ((x - stroke.start.x) * dx + (y - stroke.start.y) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const projX = stroke.start.x + t * dx; + const projY = stroke.start.y + t * dy; + const dist = Math.sqrt((x - projX) ** 2 + (y - projY) ** 2); + if (dist <= HIT_TOLERANCE) return i; + } + } + } + return null; +} + +/** + * Returns a new stroke with all coordinates shifted by (dx, dy). + */ +export function translateStroke(stroke: Stroke, dx: number, dy: number): Stroke { + const result = { ...stroke }; + + if (result.start) { + result.start = { x: result.start.x + dx, y: result.start.y + dy }; + } + if (result.end) { + result.end = { x: result.end.x + dx, y: result.end.y + dy }; + } + if (result.position) { + result.position = { x: result.position.x + dx, y: result.position.y + dy }; + } + if (result.points) { + result.points = result.points.map(p => ({ x: p.x + dx, y: p.y + dy })); + } + + return result; +} + +// ── Text Hit-Testing ──────────────────────────────────────────────────────── + +/** + * Find a text stroke at the given position. + * Uses an approximate bounding box based on character count and font size. + * Returns the index of the topmost matching text stroke, or null. + */ +export function findTextAtPosition( + strokes: Stroke[], + x: number, + y: number, + defaultFontSize: number = DEFAULT_FONT_SIZE +): number | null { + for (let i = strokes.length - 1; i >= 0; i--) { + const stroke = strokes[i]; + if (stroke.tool !== 'text' || !stroke.text || !stroke.position) continue; + + const fontSize = stroke.fontSize || defaultFontSize; + const lines = stroke.text.split('\n'); + const lineHeight = fontSize * 1.3; + // Approximate width: ~0.6em per character, use widest line + const maxLineLen = Math.max(...lines.map(l => l.length)); + const approxWidth = Math.max(maxLineLen * fontSize * 0.6, 20); + const approxHeight = lines.length * lineHeight; + + const minX = stroke.position.x; + const minY = stroke.position.y; + const maxX = minX + approxWidth; + const maxY = minY + approxHeight; + + if (x >= minX && x <= maxX && y >= minY && y <= maxY) { + return i; + } + } + return null; +} + // ── Undo / Redo History ───────────────────────────────────────────────────── export interface UndoRedoResult { diff --git a/src/stores/canvasStore.ts b/src/stores/canvasStore.ts index 8faee11..c33dbea 100644 --- a/src/stores/canvasStore.ts +++ b/src/stores/canvasStore.ts @@ -17,6 +17,7 @@ interface CanvasState { zoom: number; panOffset: Point; drawingStrokes: Stroke[]; + strokeVersion: number; undoHistory: Stroke[][]; redoHistory: Stroke[][]; remoteDrawers: Record; @@ -48,6 +49,7 @@ const initialState: CanvasState = { zoom: 1, panOffset: { x: 0, y: 0 }, drawingStrokes: [], + strokeVersion: 0, undoHistory: [], redoHistory: [], remoteDrawers: {}, @@ -117,7 +119,7 @@ export const useCanvasStore = create((set) => ({ if (index < 0 || index >= state.drawingStrokes.length) return state; const drawingStrokes = [...state.drawingStrokes]; drawingStrokes[index] = { ...drawingStrokes[index], ...updates }; - return { drawingStrokes }; + return { drawingStrokes, strokeVersion: state.strokeVersion + 1 }; }), updateRemoteDrawer: (peerId, data) => diff --git a/src/styles.css b/src/styles.css index ec3db70..d05ed56 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1200,7 +1200,7 @@ button:disabled { z-index: 100; } -.text-input-overlay input { +.text-input-overlay textarea { font-family: sans-serif; font-size: 16px; padding: 4px 8px; @@ -1211,9 +1211,13 @@ button:disabled { outline: none; min-width: 100px; max-width: 300px; + resize: none; + overflow: hidden; + white-space: pre-wrap; + line-height: 1.3; } -.text-input-overlay input:focus { +.text-input-overlay textarea:focus { box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.3); } diff --git a/tests/e2e/text-tool.spec.ts b/tests/e2e/text-tool.spec.ts index 673ea05..b7a775f 100644 --- a/tests/e2e/text-tool.spec.ts +++ b/tests/e2e/text-tool.spec.ts @@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test'; * Test text tool functionality * Validates: * 1. Text tool click shows text input at correct position - * 2. Typing and pressing Enter commits text to canvas + * 2. Typing and pressing Ctrl+Enter commits text to canvas * 3. Double-click on shapes shows text input * 4. Text on shapes commits correctly * 5. Text syncs between participants @@ -56,9 +56,9 @@ test.describe('Text Tool', () => { expect(textInputVisible).toBe(true); console.log('SUCCESS: Text input is visible'); - console.log('Step 6: Type text and press Enter...'); + console.log('Step 6: Type text and press Ctrl+Enter...'); await page.keyboard.type('Hello Canvas'); - await page.keyboard.press('Enter'); + await page.keyboard.press('Control+Enter'); // Wait for text to be committed await page.waitForTimeout(500); @@ -144,9 +144,9 @@ test.describe('Text Tool', () => { expect(textInputVisible).toBe(true); console.log('SUCCESS: Text input appears on double-click'); - console.log('Step 7: Type text and press Enter...'); + console.log('Step 7: Type text and press Ctrl+Enter...'); await page.keyboard.type('Rectangle Label'); - await page.keyboard.press('Enter'); + await page.keyboard.press('Control+Enter'); await page.waitForTimeout(500); @@ -208,7 +208,7 @@ test.describe('Text Tool', () => { await page1.mouse.click(box1!.x + 250, box1!.y + 250); await page1.waitForTimeout(300); await page1.keyboard.type('Synced Text'); - await page1.keyboard.press('Enter'); + await page1.keyboard.press('Control+Enter'); console.log('Step 5: Verify User2 received the text...'); // Poll for sync instead of fixed wait @@ -229,7 +229,7 @@ test.describe('Text Tool', () => { await page2.mouse.click(box2!.x + 400, box2!.y + 200); await page2.waitForTimeout(300); await page2.keyboard.type('User2 Text'); - await page2.keyboard.press('Enter'); + await page2.keyboard.press('Control+Enter'); console.log('Step 7: Verify User1 received text from User2...'); // Poll for sync instead of fixed wait diff --git a/tests/unit/TextInputOverlay.test.tsx b/tests/unit/TextInputOverlay.test.tsx index 712557d..b3b0feb 100644 --- a/tests/unit/TextInputOverlay.test.tsx +++ b/tests/unit/TextInputOverlay.test.tsx @@ -26,11 +26,11 @@ describe('TextInputOverlay', () => { expect(document.activeElement).toBe(input); }); - it('calls onCommit with text when Enter is pressed', () => { + it('calls onCommit with text when Ctrl+Enter is pressed', () => { render(); const input = screen.getByPlaceholderText('Enter text...'); fireEvent.change(input, { target: { value: 'Hello' } }); - fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }); expect(defaultProps.onCommit).toHaveBeenCalledWith('Hello'); }); diff --git a/tests/unit/canvasSelect.test.tsx b/tests/unit/canvasSelect.test.tsx new file mode 100644 index 0000000..0162f91 --- /dev/null +++ b/tests/unit/canvasSelect.test.tsx @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from '../../src/stores/canvasStore'; +import { + findStrokeAtPosition, + getStrokeBounds, + translateStroke, +} from '../../src/services/canvas-logic'; +import type { Stroke } from '../../src/services/canvas-logic'; + +/** + * Tests for selection tool functionality. + * + * Requirements: + * - findStrokeAtPosition correctly hit-tests all stroke types + * - getStrokeBounds returns correct bounding box for all stroke types + * - translateStroke correctly shifts coordinates for all stroke types + * - Select and move updates stroke position in store + */ + +describe('Canvas selection tool', () => { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + describe('findStrokeAtPosition', () => { + it('should find a rectangle stroke', () => { + const strokes: Stroke[] = [ + { tool: 'rectangle', start: { x: 100, y: 100 }, end: { x: 200, y: 200 }, color: '#fff' }, + ]; + expect(findStrokeAtPosition(strokes, 150, 150)).toBe(0); + }); + + it('should find a circle stroke', () => { + const strokes: Stroke[] = [ + { tool: 'circle', start: { x: 200, y: 200 }, end: { x: 250, y: 200 }, color: '#fff' }, + ]; + // Point inside circle (radius=50, center=200,200) + expect(findStrokeAtPosition(strokes, 210, 210)).toBe(0); + }); + + it('should find a text stroke', () => { + const strokes: Stroke[] = [ + { tool: 'text', text: 'Hello', position: { x: 100, y: 100 }, fontSize: 16, color: '#fff' }, + ]; + expect(findStrokeAtPosition(strokes, 110, 108)).toBe(0); + }); + + it('should find a pen stroke', () => { + const strokes: Stroke[] = [ + { + tool: 'pen', + points: [{ x: 100, y: 100 }, { x: 150, y: 150 }, { x: 200, y: 100 }], + color: '#fff', + brushSize: 2, + }, + ]; + // Point near (150, 150) + expect(findStrokeAtPosition(strokes, 152, 148)).toBe(0); + }); + + it('should find a line stroke', () => { + const strokes: Stroke[] = [ + { tool: 'line', start: { x: 100, y: 100 }, end: { x: 200, y: 200 }, color: '#fff' }, + ]; + // Point near the midpoint of the line + expect(findStrokeAtPosition(strokes, 150, 150)).toBe(0); + }); + + it('should return null for empty area', () => { + const strokes: Stroke[] = [ + { tool: 'rectangle', start: { x: 100, y: 100 }, end: { x: 200, y: 200 }, color: '#fff' }, + ]; + expect(findStrokeAtPosition(strokes, 500, 500)).toBeNull(); + }); + + it('should find topmost stroke when overlapping', () => { + const strokes: Stroke[] = [ + { tool: 'rectangle', start: { x: 100, y: 100 }, end: { x: 300, y: 300 }, color: '#fff' }, + { tool: 'rectangle', start: { x: 150, y: 150 }, end: { x: 250, y: 250 }, color: '#fff' }, + ]; + expect(findStrokeAtPosition(strokes, 200, 200)).toBe(1); + }); + }); + + describe('getStrokeBounds', () => { + it('should return bounds for rectangle', () => { + const stroke: Stroke = { tool: 'rectangle', start: { x: 100, y: 100 }, end: { x: 200, y: 200 } }; + const bounds = getStrokeBounds(stroke); + expect(bounds).toEqual({ minX: 100, minY: 100, maxX: 200, maxY: 200 }); + }); + + it('should handle rectangle drawn right-to-left', () => { + const stroke: Stroke = { tool: 'rectangle', start: { x: 200, y: 200 }, end: { x: 100, y: 100 } }; + const bounds = getStrokeBounds(stroke); + expect(bounds).toEqual({ minX: 100, minY: 100, maxX: 200, maxY: 200 }); + }); + + it('should return bounds for circle', () => { + const stroke: Stroke = { tool: 'circle', start: { x: 200, y: 200 }, end: { x: 250, y: 200 } }; + const bounds = getStrokeBounds(stroke); + // radius=50, so bounds = 150..250, 150..250 + expect(bounds).toEqual({ minX: 150, minY: 150, maxX: 250, maxY: 250 }); + }); + + it('should return bounds for pen stroke', () => { + const stroke: Stroke = { + tool: 'pen', + points: [{ x: 50, y: 100 }, { x: 200, y: 50 }, { x: 150, y: 300 }], + }; + const bounds = getStrokeBounds(stroke); + expect(bounds).toEqual({ minX: 50, minY: 50, maxX: 200, maxY: 300 }); + }); + + it('should return bounds for text stroke', () => { + const stroke: Stroke = { + tool: 'text', + text: 'Hello', + position: { x: 100, y: 200 }, + fontSize: 16, + }; + const bounds = getStrokeBounds(stroke); + expect(bounds.minX).toBe(100); + expect(bounds.minY).toBe(200); + expect(bounds.maxX).toBeGreaterThan(100); + expect(bounds.maxY).toBeGreaterThan(200); + }); + + it('should return bounds for line', () => { + const stroke: Stroke = { tool: 'line', start: { x: 50, y: 100 }, end: { x: 200, y: 300 } }; + const bounds = getStrokeBounds(stroke); + expect(bounds).toEqual({ minX: 50, minY: 100, maxX: 200, maxY: 300 }); + }); + }); + + describe('translateStroke', () => { + it('should translate rectangle', () => { + const stroke: Stroke = { tool: 'rectangle', start: { x: 100, y: 100 }, end: { x: 200, y: 200 }, color: '#fff' }; + const translated = translateStroke(stroke, 50, -30); + expect(translated.start).toEqual({ x: 150, y: 70 }); + expect(translated.end).toEqual({ x: 250, y: 170 }); + }); + + it('should translate circle', () => { + const stroke: Stroke = { tool: 'circle', start: { x: 200, y: 200 }, end: { x: 250, y: 200 }, color: '#fff' }; + const translated = translateStroke(stroke, 10, 20); + expect(translated.start).toEqual({ x: 210, y: 220 }); + expect(translated.end).toEqual({ x: 260, y: 220 }); + }); + + it('should translate pen stroke', () => { + const stroke: Stroke = { + tool: 'pen', + points: [{ x: 100, y: 100 }, { x: 150, y: 150 }], + color: '#fff', + }; + const translated = translateStroke(stroke, 20, 30); + expect(translated.points).toEqual([{ x: 120, y: 130 }, { x: 170, y: 180 }]); + }); + + it('should translate text stroke', () => { + const stroke: Stroke = { + tool: 'text', + text: 'Hi', + position: { x: 100, y: 200 }, + fontSize: 16, + color: '#fff', + }; + const translated = translateStroke(stroke, -10, 15); + expect(translated.position).toEqual({ x: 90, y: 215 }); + }); + + it('should translate line stroke', () => { + const stroke: Stroke = { tool: 'line', start: { x: 50, y: 50 }, end: { x: 150, y: 100 }, color: '#fff' }; + const translated = translateStroke(stroke, 25, 25); + expect(translated.start).toEqual({ x: 75, y: 75 }); + expect(translated.end).toEqual({ x: 175, y: 125 }); + }); + + it('should preserve non-coordinate properties', () => { + const stroke: Stroke = { + tool: 'rectangle', + start: { x: 100, y: 100 }, + end: { x: 200, y: 200 }, + color: '#ff0000', + brushSize: 3, + text: 'Label', + }; + const translated = translateStroke(stroke, 10, 10); + expect(translated.color).toBe('#ff0000'); + expect(translated.brushSize).toBe(3); + expect(translated.text).toBe('Label'); + }); + }); + + describe('Store operations for selection/move', () => { + it('should update stroke position via updateStrokeAt after move', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'rectangle', + color: '#ffffff', + start: { x: 100, y: 100 }, + end: { x: 200, y: 200 }, + }); + + // Simulate moving the rectangle by (50, 30) + const stroke = useCanvasStore.getState().drawingStrokes[0]; + const moved = translateStroke(stroke, 50, 30); + useCanvasStore.getState().updateStrokeAt(0, moved); + + const updated = useCanvasStore.getState().drawingStrokes[0]; + expect(updated.start).toEqual({ x: 150, y: 130 }); + expect(updated.end).toEqual({ x: 250, y: 230 }); + }); + }); +}); diff --git a/tests/unit/canvasShapeText.test.tsx b/tests/unit/canvasShapeText.test.tsx new file mode 100644 index 0000000..756d235 --- /dev/null +++ b/tests/unit/canvasShapeText.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useCanvasStore } from '../../src/stores/canvasStore'; +import TextInputOverlay from '../../src/components/DiagramCanvas/TextInputOverlay'; +import { getShapeCenter } from '../../src/services/canvas-logic'; + +/** + * Tests for double-click on shapes to add/edit text. + * + * Requirements: + * - Double-click on rectangle/circle opens text overlay at shape center + * - Existing shape text pre-fills the overlay (initialText prop) + * - Committing updates the shape's text via updateStrokeAt + */ + +describe('Canvas shape text', () => { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + describe('getShapeCenter', () => { + it('should return center of rectangle', () => { + const center = getShapeCenter({ + tool: 'rectangle', + start: { x: 100, y: 100 }, + end: { x: 300, y: 200 }, + }); + expect(center).toEqual({ x: 200, y: 150 }); + }); + + it('should return center of circle (its start point)', () => { + const center = getShapeCenter({ + tool: 'circle', + start: { x: 250, y: 250 }, + end: { x: 350, y: 250 }, + }); + expect(center).toEqual({ x: 250, y: 250 }); + }); + }); + + describe('TextInputOverlay with initialText', () => { + it('should pre-fill textarea with initialText', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...') as HTMLTextAreaElement; + expect(textarea.value).toBe('Existing Label'); + }); + + it('should commit edited shape text', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'New Label' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + + expect(onCommit).toHaveBeenCalledWith('New Label'); + }); + }); + + describe('Store updateStrokeAt for shape text', () => { + it('should update rectangle text via updateStrokeAt', () => { + const store = useCanvasStore.getState(); + + // Add a rectangle + store.addStroke({ + tool: 'rectangle', + color: '#ffffff', + start: { x: 100, y: 100 }, + end: { x: 300, y: 200 }, + }); + + // Update its text (like double-click → commit does) + useCanvasStore.getState().updateStrokeAt(0, { text: 'Rectangle Label' }); + + const updated = useCanvasStore.getState(); + expect(updated.drawingStrokes[0].text).toBe('Rectangle Label'); + expect(updated.drawingStrokes[0].tool).toBe('rectangle'); + }); + + it('should update circle text via updateStrokeAt', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'circle', + color: '#ffffff', + start: { x: 250, y: 250 }, + end: { x: 350, y: 250 }, + }); + + useCanvasStore.getState().updateStrokeAt(0, { text: 'Circle Label' }); + + const updated = useCanvasStore.getState(); + expect(updated.drawingStrokes[0].text).toBe('Circle Label'); + }); + + it('should replace existing shape text', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'rectangle', + color: '#ffffff', + start: { x: 100, y: 100 }, + end: { x: 300, y: 200 }, + text: 'Old Text', + }); + + useCanvasStore.getState().updateStrokeAt(0, { text: 'Updated Text' }); + + const updated = useCanvasStore.getState(); + expect(updated.drawingStrokes[0].text).toBe('Updated Text'); + }); + }); +}); diff --git a/tests/unit/canvasSync.test.ts b/tests/unit/canvasSync.test.ts new file mode 100644 index 0000000..2a057a3 --- /dev/null +++ b/tests/unit/canvasSync.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from '../../src/stores/canvasStore'; + +/** + * Tests for WebRTC sync of in-place stroke updates. + * + * Problem: useCanvasSync only detects array length changes. + * updateStrokeAt creates a new array ref but same length — no sync. + * + * Fix: Add strokeVersion counter that increments on updateStrokeAt, + * enabling useCanvasSync to detect in-place mutations. + */ + +describe('Canvas sync for updateStrokeAt', () => { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + it('should have strokeVersion starting at 0', () => { + const state = useCanvasStore.getState(); + expect(state.strokeVersion).toBe(0); + }); + + it('should increment strokeVersion when updateStrokeAt is called', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'rectangle', + color: '#ffffff', + start: { x: 100, y: 100 }, + end: { x: 200, y: 200 }, + }); + + const before = useCanvasStore.getState().strokeVersion; + useCanvasStore.getState().updateStrokeAt(0, { text: 'Hello' }); + const after = useCanvasStore.getState().strokeVersion; + + expect(after).toBe(before + 1); + }); + + it('should not change strokeVersion on addStroke', () => { + const before = useCanvasStore.getState().strokeVersion; + + useCanvasStore.getState().addStroke({ + tool: 'text', + text: 'Test', + color: '#fff', + position: { x: 0, y: 0 }, + fontSize: 16, + }); + + const after = useCanvasStore.getState().strokeVersion; + expect(after).toBe(before); + }); + + it('should increment strokeVersion on each updateStrokeAt call', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'text', + text: 'A', + color: '#fff', + position: { x: 0, y: 0 }, + fontSize: 16, + }); + + useCanvasStore.getState().updateStrokeAt(0, { text: 'B' }); + useCanvasStore.getState().updateStrokeAt(0, { text: 'C' }); + useCanvasStore.getState().updateStrokeAt(0, { text: 'D' }); + + expect(useCanvasStore.getState().strokeVersion).toBe(3); + }); + + it('should keep same array length after updateStrokeAt', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'text', + text: 'Original', + color: '#fff', + position: { x: 0, y: 0 }, + fontSize: 16, + }); + + const lengthBefore = useCanvasStore.getState().drawingStrokes.length; + useCanvasStore.getState().updateStrokeAt(0, { text: 'Updated' }); + const lengthAfter = useCanvasStore.getState().drawingStrokes.length; + + expect(lengthAfter).toBe(lengthBefore); + }); +}); diff --git a/tests/unit/canvasTextCommit.test.tsx b/tests/unit/canvasTextCommit.test.tsx index 6d5b9ff..292a3ce 100644 --- a/tests/unit/canvasTextCommit.test.tsx +++ b/tests/unit/canvasTextCommit.test.tsx @@ -12,9 +12,9 @@ import TextInputOverlay from '../../src/components/DiagramCanvas/TextInputOverla * its blur handler can read the input value. * * These tests verify: - * 1. Text commit via Enter key works correctly + * 1. Text commit via Ctrl+Enter works correctly * 2. Text commit via blur (click outside) works correctly - * 3. No double-commit when Enter is followed by blur on unmount + * 3. No double-commit when Ctrl+Enter is followed by blur on unmount */ describe('Canvas text commit', () => { @@ -23,7 +23,7 @@ describe('Canvas text commit', () => { }); describe('TextInputOverlay commit behavior', () => { - it('should call onCommit with text value when Enter is pressed', () => { + it('should call onCommit with text value when Ctrl+Enter is pressed', () => { const onCommit = vi.fn(); const onDismiss = vi.fn(); @@ -37,7 +37,7 @@ describe('Canvas text commit', () => { const input = screen.getByPlaceholderText('Enter text...'); fireEvent.change(input, { target: { value: 'Enter Text' } }); - fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }); expect(onCommit).toHaveBeenCalledWith('Enter Text'); expect(onCommit).toHaveBeenCalledTimes(1); @@ -71,7 +71,7 @@ describe('Canvas text commit', () => { dateSpy.mockRestore(); }); - it('should not double-commit when Enter is followed by unmount blur', () => { + it('should not double-commit when Ctrl+Enter is followed by unmount blur', () => { const onCommit = vi.fn(); const onDismiss = vi.fn(); @@ -85,7 +85,7 @@ describe('Canvas text commit', () => { const input = screen.getByPlaceholderText('Enter text...'); fireEvent.change(input, { target: { value: 'Test Text' } }); - fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }); expect(onCommit).toHaveBeenCalledTimes(1); expect(onCommit).toHaveBeenCalledWith('Test Text'); diff --git a/tests/unit/canvasTextEdit.test.tsx b/tests/unit/canvasTextEdit.test.tsx new file mode 100644 index 0000000..4cd8f86 --- /dev/null +++ b/tests/unit/canvasTextEdit.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useCanvasStore } from '../../src/stores/canvasStore'; +import TextInputOverlay from '../../src/components/DiagramCanvas/TextInputOverlay'; +import { findTextAtPosition } from '../../src/services/canvas-logic'; + +/** + * Tests for clicking existing text to edit. + * + * Requirements: + * - Click on existing text stroke opens overlay with pre-filled text + * - Editing and committing updates the stroke in-place via updateStrokeAt + * - Click on empty canvas still creates new text + * - findTextAtPosition correctly hit-tests text strokes + */ + +describe('Canvas text edit (click to edit)', () => { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + describe('findTextAtPosition', () => { + it('should find text stroke at its position', () => { + const strokes = [ + { + tool: 'text' as const, + text: 'Hello', + position: { x: 100, y: 200 }, + fontSize: 16, + color: '#fff', + }, + ]; + + const index = findTextAtPosition(strokes, 110, 208); + expect(index).toBe(0); + }); + + it('should return null when clicking away from text', () => { + const strokes = [ + { + tool: 'text' as const, + text: 'Hello', + position: { x: 100, y: 200 }, + fontSize: 16, + color: '#fff', + }, + ]; + + const index = findTextAtPosition(strokes, 500, 500); + expect(index).toBeNull(); + }); + + it('should find the topmost (last) text stroke when overlapping', () => { + const strokes = [ + { + tool: 'text' as const, + text: 'Bottom', + position: { x: 100, y: 200 }, + fontSize: 16, + color: '#fff', + }, + { + tool: 'text' as const, + text: 'Top', + position: { x: 105, y: 205 }, + fontSize: 16, + color: '#fff', + }, + ]; + + const index = findTextAtPosition(strokes, 110, 210); + expect(index).toBe(1); // topmost + }); + + it('should not match non-text strokes', () => { + const strokes = [ + { + tool: 'rectangle' as const, + start: { x: 100, y: 100 }, + end: { x: 200, y: 200 }, + color: '#fff', + }, + ]; + + const index = findTextAtPosition(strokes, 150, 150); + expect(index).toBeNull(); + }); + }); + + describe('Store update for text edits', () => { + it('should update text stroke in-place via updateStrokeAt', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'text', + text: 'Original', + color: '#ffffff', + position: { x: 100, y: 200 }, + fontSize: 16, + }); + + useCanvasStore.getState().updateStrokeAt(0, { text: 'Edited' }); + + const updated = useCanvasStore.getState(); + expect(updated.drawingStrokes[0].text).toBe('Edited'); + expect(updated.drawingStrokes[0].position).toEqual({ x: 100, y: 200 }); + }); + + it('should update multi-line text in-place', () => { + const store = useCanvasStore.getState(); + + store.addStroke({ + tool: 'text', + text: 'Line 1', + color: '#ffffff', + position: { x: 100, y: 200 }, + fontSize: 16, + }); + + useCanvasStore.getState().updateStrokeAt(0, { text: 'Line 1\nLine 2\nLine 3' }); + + const updated = useCanvasStore.getState(); + expect(updated.drawingStrokes[0].text).toBe('Line 1\nLine 2\nLine 3'); + }); + }); + + describe('TextInputOverlay pre-fill for editing', () => { + it('should pre-fill with existing text when editing', () => { + const onCommit = vi.fn(); + + render( + {}} + initialText="Existing text to edit" + />, + ); + + const textarea = screen.getByPlaceholderText('Enter text...') as HTMLTextAreaElement; + expect(textarea.value).toBe('Existing text to edit'); + + // Edit and commit + fireEvent.change(textarea, { target: { value: 'Modified text' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + + expect(onCommit).toHaveBeenCalledWith('Modified text'); + }); + }); +}); diff --git a/tests/unit/canvasTextMultiline.test.tsx b/tests/unit/canvasTextMultiline.test.tsx new file mode 100644 index 0000000..fb7fe4a --- /dev/null +++ b/tests/unit/canvasTextMultiline.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TextInputOverlay from '../../src/components/DiagramCanvas/TextInputOverlay'; + +/** + * Tests for multi-line text input behavior. + * + * Requirements: + * - Enter key adds a newline (does NOT commit) + * - Ctrl+Enter (or Cmd+Enter) commits the text + * - Blur commits the text (after grace period) + * - Escape dismisses without commit + */ + +describe('Multi-line text input', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should render a textarea (not an input)', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = document.getElementById('canvasTextInput'); + expect(textarea).not.toBeNull(); + expect(textarea!.tagName.toLowerCase()).toBe('textarea'); + }); + + it('Enter key should NOT commit (allows newline)', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(textarea, { target: { value: 'Line 1' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + // Enter should NOT trigger commit + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('Ctrl+Enter should commit text', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(textarea, { target: { value: 'Multi\nLine' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + + expect(onCommit).toHaveBeenCalledWith('Multi\nLine'); + expect(onCommit).toHaveBeenCalledTimes(1); + }); + + it('Meta+Enter (Cmd) should commit text', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(textarea, { target: { value: 'Cmd Text' } }); + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }); + + expect(onCommit).toHaveBeenCalledWith('Cmd Text'); + }); + + it('Escape should dismiss without committing', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(textarea, { target: { value: 'Will dismiss' } }); + fireEvent.keyDown(textarea, { key: 'Escape' }); + + expect(onDismiss).toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('blur should commit text after grace period', () => { + const now = Date.now(); + const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now); + + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(textarea, { target: { value: 'Blur commit' } }); + + dateSpy.mockReturnValue(now + 400); + fireEvent.blur(textarea); + + expect(onCommit).toHaveBeenCalledWith('Blur commit'); + + dateSpy.mockRestore(); + }); + + it('should accept initialText prop and pre-fill the textarea', () => { + const onCommit = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter text...') as HTMLTextAreaElement; + expect(textarea.value).toBe('Existing text'); + }); +}); diff --git a/tests/unit/canvasTextPosition.test.ts b/tests/unit/canvasTextPosition.test.ts new file mode 100644 index 0000000..3a0514e --- /dev/null +++ b/tests/unit/canvasTextPosition.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Tests for canvas text positioning accuracy. + * + * Bug: Text shifts after the text input overlay loses focus because the + * overlay's CSS padding+border offset isn't compensated for. The input + * text appears at (left + padding + border, top + padding + border) but + * ctx.fillText renders at the raw logical position — causing a visible jump. + * + * The fix: getOverlayScreenPos subtracts the padding+border offset so + * the rendered text in the input visually aligns with ctx.fillText output. + */ + +// The overlay CSS defines: padding 4px 8px, border 2px +// So total offset: left = 8 + 2 = 10px, top = 4 + 2 = 6px +const OVERLAY_PADDING_LEFT = 8; +const OVERLAY_BORDER = 2; +const OVERLAY_PADDING_TOP = 4; +const OVERLAY_OFFSET_LEFT = OVERLAY_PADDING_LEFT + OVERLAY_BORDER; // 10 +const OVERLAY_OFFSET_TOP = OVERLAY_PADDING_TOP + OVERLAY_BORDER; // 6 + +describe('Canvas text positioning', () => { + it('overlay offset constants match CSS padding+border', () => { + // If CSS changes, these constants must be updated + expect(OVERLAY_OFFSET_LEFT).toBe(10); + expect(OVERLAY_OFFSET_TOP).toBe(6); + }); + + it('getOverlayScreenPos should subtract padding+border offset', () => { + // Simulate: logical position (200, 300), zoom=1, pan=(0,0), cssScale=1 + const logicalX = 200; + const logicalY = 300; + const zoom = 1; + const panX = 0; + const panY = 0; + + // Without fix: overlay.left = logicalX * zoom + panX = 200 + // With fix: overlay.left = 200 - 10 = 190 (so input text starts at 200) + const bufferX = logicalX * zoom + panX; + const bufferY = logicalY * zoom + panY; + + // Assuming cssScale = 1 for simplicity + const cssScaleX = 1; + const cssScaleY = 1; + + const correctedLeft = bufferX * cssScaleX - OVERLAY_OFFSET_LEFT; + const correctedTop = bufferY * cssScaleY - OVERLAY_OFFSET_TOP; + + expect(correctedLeft).toBe(190); + expect(correctedTop).toBe(294); + }); + + it('text inside the input should align with canvas fillText position', () => { + // The input text starts at: overlay.left + padding + border + // After correction: (200 - 10) + 10 = 200 — matches ctx.fillText(text, 200, ...) + const logicalX = 200; + const logicalY = 300; + + const overlayLeft = logicalX - OVERLAY_OFFSET_LEFT; + const overlayTop = logicalY - OVERLAY_OFFSET_TOP; + + const inputTextX = overlayLeft + OVERLAY_OFFSET_LEFT; + const inputTextY = overlayTop + OVERLAY_OFFSET_TOP; + + expect(inputTextX).toBe(logicalX); + expect(inputTextY).toBe(logicalY); + }); + + it('position correction works with zoom and pan', () => { + const logicalX = 150; + const logicalY = 250; + const zoom = 2; + const panX = 50; + const panY = 30; + const cssScaleX = 0.5; + const cssScaleY = 0.5; + + const bufferX = logicalX * zoom + panX; // 150*2+50 = 350 + const bufferY = logicalY * zoom + panY; // 250*2+30 = 530 + + const screenX = bufferX * cssScaleX; // 175 + const screenY = bufferY * cssScaleY; // 265 + + const correctedLeft = screenX - OVERLAY_OFFSET_LEFT; // 165 + const correctedTop = screenY - OVERLAY_OFFSET_TOP; // 259 + + // Input text appears at: correctedLeft + offset = 165 + 10 = 175 = screenX + expect(correctedLeft + OVERLAY_OFFSET_LEFT).toBe(screenX); + expect(correctedTop + OVERLAY_OFFSET_TOP).toBe(screenY); + }); +}); From fee38c2a7db16acae458926d2cc29291f16e79c8 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 18:20:46 -0800 Subject: [PATCH 3/6] Fix Tab key in code editor to insert 4 spaces instead of changing focus Intercept Tab keydown in the code textarea, preventDefault to stop focus change, and insert 4 spaces at cursor position. Integrates with the existing OT pipeline so tab insertions sync to peers. --- src/components/CodeEditor/CodeEditor.tsx | 33 ++++++++- tests/unit/codeEditorTab.test.tsx | 92 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/unit/codeEditorTab.test.tsx diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index fa21a31..69f3f34 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback, useEffect, ChangeEvent } from 'react'; +import { useRef, useCallback, useEffect, ChangeEvent, KeyboardEvent } from 'react'; import Prism from 'prismjs'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-python'; @@ -46,6 +46,36 @@ export default function CodeEditor() { [setCode, applyLocalOperation], ); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault(); + const textarea = inputRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const oldCode = textarea.value; + const spaces = ' '; + const newCode = oldCode.substring(0, start) + spaces + oldCode.substring(end); + + calculateTextOperation(oldCode, newCode); + applyLocalOperation(); + setCode(newCode); + prevCodeRef.current = newCode; + + // Restore cursor position after the inserted spaces + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.selectionStart = start + spaces.length; + inputRef.current.selectionEnd = start + spaces.length; + } + }); + } + }, + [setCode, applyLocalOperation], + ); + // Keep prevCodeRef in sync with external code changes (remote operations) useEffect(() => { prevCodeRef.current = code; @@ -73,6 +103,7 @@ export default function CodeEditor() { ref={inputRef} value={code} onChange={handleInput} + onKeyDown={handleKeyDown} onScroll={handleScroll} spellCheck={false} autoCapitalize="off" diff --git a/tests/unit/codeEditorTab.test.tsx b/tests/unit/codeEditorTab.test.tsx new file mode 100644 index 0000000..0cd12bc --- /dev/null +++ b/tests/unit/codeEditorTab.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useEditorStore } from '../../src/stores/editorStore'; +import CodeEditor from '../../src/components/CodeEditor/CodeEditor'; + +/** + * Tests for Tab key behavior in the code editor. + * + * Regression: Pressing Tab in the code textarea moves focus away from + * the editor instead of inserting indentation. + * + * Fix: Intercept Tab keydown, preventDefault, insert 4 spaces at cursor. + */ + +describe('Code editor Tab key', () => { + beforeEach(() => { + useEditorStore.getState().setCode(''); + }); + + it('should prevent default Tab behavior (focus change)', () => { + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + const event = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + const prevented = !textarea.dispatchEvent(event); + + expect(prevented).toBe(true); + }); + + it('should insert 4 spaces when Tab is pressed at start', () => { + useEditorStore.getState().setCode('hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + // Cursor at start (default position 0) + textarea.selectionStart = 0; + textarea.selectionEnd = 0; + + fireEvent.keyDown(textarea, { key: 'Tab' }); + + const updatedCode = useEditorStore.getState().code; + expect(updatedCode).toBe(' hello'); + }); + + it('should insert spaces at the cursor position', () => { + useEditorStore.getState().setCode('abc'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 1; + textarea.selectionEnd = 1; + + fireEvent.keyDown(textarea, { key: 'Tab' }); + + const updatedCode = useEditorStore.getState().code; + expect(updatedCode).toBe('a bc'); + }); + + it('should replace selected text with 4 spaces', () => { + useEditorStore.getState().setCode('hello world'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + // Select "llo wo" + textarea.selectionStart = 2; + textarea.selectionEnd = 8; + + fireEvent.keyDown(textarea, { key: 'Tab' }); + + const updatedCode = useEditorStore.getState().code; + expect(updatedCode).toBe('he rld'); + }); + + it('should not interfere with other key presses', () => { + useEditorStore.getState().setCode('test'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + // Regular key should not be prevented + const event = new KeyboardEvent('keydown', { + key: 'a', + bubbles: true, + cancelable: true, + }); + const prevented = !textarea.dispatchEvent(event); + expect(prevented).toBe(false); + }); +}); From dcc1762e88e824d8df4a368012b9f446508d24c2 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 18:24:45 -0800 Subject: [PATCH 4/6] Add Shift+Tab dedent support in code editor Shift+Tab removes up to 4 leading spaces from the current line or all selected lines. Only removes space characters, stops at non-space. Pure dedentLines() function extracted to code-editor-logic.ts for testability. 19 new tests (14 unit + 5 integration). --- src/components/CodeEditor/CodeEditor.tsx | 45 ++++-- src/services/code-editor-logic.ts | 86 +++++++++++ tests/unit/codeEditorShiftTab.test.tsx | 179 +++++++++++++++++++++++ 3 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 tests/unit/codeEditorShiftTab.test.tsx diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 69f3f34..204f533 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -17,7 +17,7 @@ import 'prismjs/components/prism-markup-templating'; import 'prismjs/components/prism-php'; import 'prismjs/components/prism-sql'; import { useEditorStore } from '../../stores/editorStore'; -import { getPrismLanguage } from '../../services/code-editor-logic'; +import { getPrismLanguage, dedentLines } from '../../services/code-editor-logic'; import { calculateTextOperation } from '../../services/ot-engine'; import RemoteCursors from './RemoteCursors'; @@ -56,21 +56,40 @@ export default function CodeEditor() { const start = textarea.selectionStart; const end = textarea.selectionEnd; const oldCode = textarea.value; - const spaces = ' '; - const newCode = oldCode.substring(0, start) + spaces + oldCode.substring(end); - calculateTextOperation(oldCode, newCode); - applyLocalOperation(); - setCode(newCode); - prevCodeRef.current = newCode; + if (e.shiftKey) { + // Shift+Tab: dedent selected lines + const { text: newCode, newStart, newEnd } = dedentLines(oldCode, start, end); + if (newCode !== oldCode) { + calculateTextOperation(oldCode, newCode); + applyLocalOperation(); + setCode(newCode); + prevCodeRef.current = newCode; - // Restore cursor position after the inserted spaces - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.selectionStart = start + spaces.length; - inputRef.current.selectionEnd = start + spaces.length; + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.selectionStart = newStart; + inputRef.current.selectionEnd = newEnd; + } + }); } - }); + } else { + // Tab: insert 4 spaces + const spaces = ' '; + const newCode = oldCode.substring(0, start) + spaces + oldCode.substring(end); + + calculateTextOperation(oldCode, newCode); + applyLocalOperation(); + setCode(newCode); + prevCodeRef.current = newCode; + + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.selectionStart = start + spaces.length; + inputRef.current.selectionEnd = start + spaces.length; + } + }); + } } }, [setCode, applyLocalOperation], diff --git a/src/services/code-editor-logic.ts b/src/services/code-editor-logic.ts index 96ac8ff..dbb66d3 100644 --- a/src/services/code-editor-logic.ts +++ b/src/services/code-editor-logic.ts @@ -34,6 +34,92 @@ export const codeTemplates: Record = { sql: '-- Write your SQL query here\nSELECT * FROM users WHERE id = 1;' }; +// ── Indentation helpers ───────────────────────────────────────────────────── + +export interface DedentResult { + text: string; + newStart: number; + newEnd: number; +} + +/** + * Remove up to 4 leading spaces from each line touched by the selection. + * Only removes space characters (' '), not tabs or other whitespace. + * Returns the new full text and adjusted selection positions. + */ +export function dedentLines( + text: string, + selectionStart: number, + selectionEnd: number, +): DedentResult { + const lines = text.split('\n'); + + // Find which lines are touched by the selection + let charCount = 0; + let startLine = 0; + let endLine = lines.length - 1; + + for (let i = 0; i < lines.length; i++) { + const lineEnd = charCount + lines[i].length; + if (charCount <= selectionStart && selectionStart <= lineEnd + 1) { + startLine = i; + } + if (charCount <= selectionEnd && selectionEnd <= lineEnd + 1) { + endLine = i; + break; + } + charCount += lines[i].length + 1; // +1 for '\n' + } + + // For cursor with no selection, just dedent the current line + if (selectionStart === selectionEnd) { + endLine = startLine; + } + + // Track total characters removed before start and end positions + let removedBeforeStart = 0; + let removedBeforeEnd = 0; + let removedSoFar = 0; + let currentOffset = 0; + + const newLines = lines.map((line, i) => { + const lineStart = currentOffset; + currentOffset += line.length + 1; + + if (i < startLine || i > endLine) return line; + + // Count leading spaces (up to 4) + let spacesToRemove = 0; + while (spacesToRemove < 4 && spacesToRemove < line.length && line[spacesToRemove] === ' ') { + spacesToRemove++; + } + + if (spacesToRemove === 0) return line; + + // Track how removal affects cursor positions + if (lineStart + spacesToRemove <= selectionStart) { + removedBeforeStart += spacesToRemove; + } else if (lineStart < selectionStart) { + removedBeforeStart += selectionStart - lineStart; + } + + if (lineStart + spacesToRemove <= selectionEnd) { + removedBeforeEnd += spacesToRemove; + } else if (lineStart < selectionEnd) { + removedBeforeEnd += selectionEnd - lineStart; + } + + removedSoFar += spacesToRemove; + return line.substring(spacesToRemove); + }); + + return { + text: newLines.join('\n'), + newStart: Math.max(0, selectionStart - removedBeforeStart), + newEnd: Math.max(0, selectionEnd - removedBeforeEnd), + }; +} + // Map language identifiers to Prism grammar names // Currently all supported languages use the same identifier, // but this provides an extension point for future mismatches diff --git a/tests/unit/codeEditorShiftTab.test.tsx b/tests/unit/codeEditorShiftTab.test.tsx new file mode 100644 index 0000000..1fe642a --- /dev/null +++ b/tests/unit/codeEditorShiftTab.test.tsx @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useEditorStore } from '../../src/stores/editorStore'; +import CodeEditor from '../../src/components/CodeEditor/CodeEditor'; +import { dedentLines } from '../../src/services/code-editor-logic'; + +/** + * Tests for Shift+Tab (dedent) behavior in the code editor. + * + * Requirements: + * - Shift+Tab removes up to 4 leading spaces from the current line + * - Only removes spaces, stops at non-space characters + * - Works with multi-line selections (dedents all selected lines) + * - Does nothing if line has no leading spaces + */ + +describe('dedentLines (pure function)', () => { + it('should remove 4 leading spaces from a single line', () => { + const result = dedentLines(' hello', 0, 9); + expect(result.text).toBe('hello'); + expect(result.newStart).toBe(0); + expect(result.newEnd).toBe(5); + }); + + it('should remove only up to 4 spaces', () => { + const result = dedentLines(' hello', 0, 11); + expect(result.text).toBe(' hello'); + }); + + it('should remove fewer than 4 spaces if that is all there is', () => { + const result = dedentLines(' hello', 0, 7); + expect(result.text).toBe('hello'); + }); + + it('should not remove non-space characters', () => { + const result = dedentLines('\thello', 0, 6); + expect(result.text).toBe('\thello'); + }); + + it('should do nothing if line has no leading spaces', () => { + const result = dedentLines('hello', 0, 5); + expect(result.text).toBe('hello'); + }); + + it('should dedent multiple selected lines', () => { + const code = ' line1\n line2\n line3'; + const result = dedentLines(code, 0, code.length); + expect(result.text).toBe('line1\nline2\nline3'); + }); + + it('should dedent lines with mixed indentation levels', () => { + const code = ' deep\n normal\n shallow\nno indent'; + const result = dedentLines(code, 0, code.length); + expect(result.text).toBe(' deep\nnormal\nshallow\nno indent'); + }); + + it('should only dedent lines within selection range', () => { + // 0123456789... + const code = 'keep\n dedent\n also\nkeep2'; + // Select from " dedent" to " also" (positions 5 to 24) + const result = dedentLines(code, 5, 24); + expect(result.text).toBe('keep\ndedent\nalso\nkeep2'); + }); + + it('should handle cursor on a single indented line (no selection)', () => { + const code = ' hello world'; + // Cursor at position 8 (after " hell") + const result = dedentLines(code, 8, 8); + expect(result.text).toBe('hello world'); + // Cursor should move back by 4 (the removed spaces) + expect(result.newStart).toBe(4); + expect(result.newEnd).toBe(4); + }); + + it('should handle line with exactly 1 space', () => { + const result = dedentLines(' x', 0, 2); + expect(result.text).toBe('x'); + }); + + it('should handle line with exactly 3 spaces', () => { + const result = dedentLines(' x', 0, 4); + expect(result.text).toBe('x'); + }); + + it('should handle empty lines in multi-line selection', () => { + const code = ' a\n\n b'; + const result = dedentLines(code, 0, code.length); + expect(result.text).toBe('a\n\nb'); + }); + + it('should adjust cursor position correctly when spaces are removed before cursor', () => { + const code = ' hello'; + // Cursor at end of line + const result = dedentLines(code, 9, 9); + expect(result.newStart).toBe(5); + expect(result.newEnd).toBe(5); + }); + + it('should not move cursor below 0', () => { + const code = ' hi'; + // Cursor at position 1 (inside the leading spaces) + const result = dedentLines(code, 1, 1); + expect(result.newStart).toBe(0); + expect(result.newEnd).toBe(0); + }); +}); + +describe('Code editor Shift+Tab integration', () => { + beforeEach(() => { + useEditorStore.getState().setCode(''); + }); + + it('should prevent default Shift+Tab behavior', () => { + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + const event = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + const prevented = !textarea.dispatchEvent(event); + expect(prevented).toBe(true); + }); + + it('should remove 4 leading spaces on Shift+Tab', () => { + useEditorStore.getState().setCode(' hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 0; + textarea.selectionEnd = 0; + + fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true }); + + expect(useEditorStore.getState().code).toBe('hello'); + }); + + it('should remove fewer spaces if line has less than 4', () => { + useEditorStore.getState().setCode(' hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 0; + textarea.selectionEnd = 0; + + fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true }); + + expect(useEditorStore.getState().code).toBe('hello'); + }); + + it('should dedent multiple selected lines', () => { + const code = ' a\n b\n c'; + useEditorStore.getState().setCode(code); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 0; + textarea.selectionEnd = code.length; + + fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true }); + + expect(useEditorStore.getState().code).toBe('a\nb\nc'); + }); + + it('should not change code if no leading spaces', () => { + useEditorStore.getState().setCode('hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 0; + textarea.selectionEnd = 0; + + fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true }); + + expect(useEditorStore.getState().code).toBe('hello'); + }); +}); From 73f00291e57592c347247caa0c39044ff0f55160 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 18:31:49 -0800 Subject: [PATCH 5/6] Fix eraser tool to use proper geometric hit-testing for all stroke types Rectangle: point-to-segment distance for all 4 edges + interior check Circle: distance from center vs radius (perimeter + interior) Line: point-to-segment distance along full length Text: bounding box with eraseRadius padding --- src/services/canvas-logic.ts | 66 ++++++++-- tests/unit/canvas-logic.test.ts | 9 +- tests/unit/canvasEraser.test.ts | 220 ++++++++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 tests/unit/canvasEraser.test.ts diff --git a/src/services/canvas-logic.ts b/src/services/canvas-logic.ts index 10ee25e..524ba8f 100644 --- a/src/services/canvas-logic.ts +++ b/src/services/canvas-logic.ts @@ -140,6 +140,19 @@ export function reconcileCoordinates( // ── Drawing / Shape Helpers ───────────────────────────────────────────────── +/** Distance from point (px,py) to the line segment (ax,ay)→(bx,by). */ +function pointToSegmentDist(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2); + let t = ((px - ax) * dx + (py - ay) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const projX = ax + t * dx; + const projY = ay + t * dy; + return Math.sqrt((px - projX) ** 2 + (py - projY) ** 2); +} + export function filterStrokesAfterErase( strokes: Stroke[], x: number, @@ -152,17 +165,50 @@ export function filterStrokesAfterErase( const dist = Math.sqrt((point.x - x) ** 2 + (point.y - y) ** 2); return dist < eraseRadius; }); - } else if (stroke.start && stroke.end) { - const startDist = Math.sqrt((stroke.start.x - x) ** 2 + (stroke.start.y - y) ** 2); - const endDist = Math.sqrt((stroke.end.x - x) ** 2 + (stroke.end.y - y) ** 2); - if (stroke.tool === 'line') { - const midX = (stroke.start.x + stroke.end.x) / 2; - const midY = (stroke.start.y + stroke.end.y) / 2; - const midDist = Math.sqrt((midX - x) ** 2 + (midY - y) ** 2); - return startDist >= eraseRadius && endDist >= eraseRadius && midDist >= eraseRadius; - } - return startDist >= eraseRadius && endDist >= eraseRadius; } + + if (stroke.tool === 'text' && stroke.position) { + const bounds = getStrokeBounds(stroke); + // Hit if within or near the bounding box + return !( + x >= bounds.minX - eraseRadius && x <= bounds.maxX + eraseRadius && + y >= bounds.minY - eraseRadius && y <= bounds.maxY + eraseRadius + ); + } + + if (stroke.tool === 'circle' && stroke.start && stroke.end) { + const radius = Math.sqrt( + (stroke.end.x - stroke.start.x) ** 2 + (stroke.end.y - stroke.start.y) ** 2 + ); + const dist = Math.sqrt((x - stroke.start.x) ** 2 + (y - stroke.start.y) ** 2); + // Hit if within the circle or near its perimeter + return dist > radius + eraseRadius; + } + + if (stroke.tool === 'rectangle' && stroke.start && stroke.end) { + const minX = Math.min(stroke.start.x, stroke.end.x); + const maxX = Math.max(stroke.start.x, stroke.end.x); + const minY = Math.min(stroke.start.y, stroke.end.y); + const maxY = Math.max(stroke.start.y, stroke.end.y); + + // Hit if inside the rectangle + if (x >= minX && x <= maxX && y >= minY && y <= maxY) return false; + + // Hit if near any of the 4 edges + const topDist = pointToSegmentDist(x, y, minX, minY, maxX, minY); + const bottomDist = pointToSegmentDist(x, y, minX, maxY, maxX, maxY); + const leftDist = pointToSegmentDist(x, y, minX, minY, minX, maxY); + const rightDist = pointToSegmentDist(x, y, maxX, minY, maxX, maxY); + + return Math.min(topDist, bottomDist, leftDist, rightDist) >= eraseRadius; + } + + if (stroke.tool === 'line' && stroke.start && stroke.end) { + const dist = pointToSegmentDist(x, y, stroke.start.x, stroke.start.y, stroke.end.x, stroke.end.y); + return dist >= eraseRadius; + } + + // Unknown stroke type — don't erase return true; }); } diff --git a/tests/unit/canvas-logic.test.ts b/tests/unit/canvas-logic.test.ts index 2ea541b..3f54c32 100644 --- a/tests/unit/canvas-logic.test.ts +++ b/tests/unit/canvas-logic.test.ts @@ -206,9 +206,14 @@ describe('filterStrokesAfterErase', () => { expect(result).toHaveLength(0); }); - it('should keep rect stroke when no endpoint is within radius', () => { - // Rect does not check midpoint (only line does) + it('should erase rect stroke when point is inside', () => { + // (250,250) is inside rect (200,200)→(300,300) const result = filterStrokesAfterErase([rectStroke], 250, 250, 5); + expect(result).toHaveLength(0); + }); + + it('should keep rect stroke when far outside', () => { + const result = filterStrokesAfterErase([rectStroke], 500, 500, 5); expect(result).toHaveLength(1); }); diff --git a/tests/unit/canvasEraser.test.ts b/tests/unit/canvasEraser.test.ts new file mode 100644 index 0000000..db4af69 --- /dev/null +++ b/tests/unit/canvasEraser.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest'; +import { filterStrokesAfterErase } from '../../src/services/canvas-logic'; +import type { Stroke } from '../../src/services/canvas-logic'; + +/** + * Tests for eraser tool hit-testing. + * + * Bug: The eraser only checks a few points (start/end/mid) on shapes, + * so erasing through the middle of a rectangle edge or circle perimeter + * doesn't remove the stroke from data. The shape visually disappears + * (pixels cleared) but reappears when redrawn (e.g. after moving). + * + * Fix: Proper geometric hit-testing for all stroke types: + * - Rectangle: point-to-segment distance for all 4 edges + interior + * - Circle: distance from center vs radius (perimeter + interior) + * - Line: point-to-segment distance along full length + * - Text: bounding box check + * - Pen: existing point proximity check (already works) + */ + +const ERASE_RADIUS = 10; + +describe('Eraser hit-testing', () => { + describe('Rectangle', () => { + const rect: Stroke = { + tool: 'rectangle', + start: { x: 100, y: 100 }, + end: { x: 200, y: 200 }, + color: '#fff', + }; + + it('should erase when hitting top edge center', () => { + const result = filterStrokesAfterErase([rect], 150, 100, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting bottom edge center', () => { + const result = filterStrokesAfterErase([rect], 150, 200, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting left edge center', () => { + const result = filterStrokesAfterErase([rect], 100, 150, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting right edge center', () => { + const result = filterStrokesAfterErase([rect], 200, 150, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting inside the rectangle', () => { + const result = filterStrokesAfterErase([rect], 150, 150, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting a corner', () => { + const result = filterStrokesAfterErase([rect], 100, 100, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should NOT erase when far from the rectangle', () => { + const result = filterStrokesAfterErase([rect], 400, 400, ERASE_RADIUS); + expect(result).toHaveLength(1); + }); + + it('should work with rectangles drawn right-to-left', () => { + const rtlRect: Stroke = { + tool: 'rectangle', + start: { x: 200, y: 200 }, + end: { x: 100, y: 100 }, + color: '#fff', + }; + // Hit middle of top edge (y=100) + const result = filterStrokesAfterErase([rtlRect], 150, 100, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + }); + + describe('Circle', () => { + // Circle: center=(200,200), edge=(250,200) → radius=50 + const circle: Stroke = { + tool: 'circle', + start: { x: 200, y: 200 }, + end: { x: 250, y: 200 }, + color: '#fff', + }; + + it('should erase when hitting the top of the perimeter', () => { + const result = filterStrokesAfterErase([circle], 200, 150, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting the bottom of the perimeter', () => { + const result = filterStrokesAfterErase([circle], 200, 250, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting the left of the perimeter', () => { + const result = filterStrokesAfterErase([circle], 150, 200, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting the center', () => { + const result = filterStrokesAfterErase([circle], 200, 200, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting the interior', () => { + const result = filterStrokesAfterErase([circle], 220, 210, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should NOT erase when far outside the circle', () => { + const result = filterStrokesAfterErase([circle], 400, 400, ERASE_RADIUS); + expect(result).toHaveLength(1); + }); + }); + + describe('Line', () => { + // Diagonal line from (100,100) to (300,300) + const line: Stroke = { + tool: 'line', + start: { x: 100, y: 100 }, + end: { x: 300, y: 300 }, + color: '#fff', + }; + + it('should erase when hitting the midpoint', () => { + const result = filterStrokesAfterErase([line], 200, 200, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting a point 25% along the line', () => { + const result = filterStrokesAfterErase([line], 150, 150, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting a point 75% along the line', () => { + const result = filterStrokesAfterErase([line], 250, 250, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting near (but not on) the line', () => { + // Point slightly off the diagonal — within erase radius + const result = filterStrokesAfterErase([line], 205, 195, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should NOT erase when far from the line', () => { + const result = filterStrokesAfterErase([line], 50, 300, ERASE_RADIUS); + expect(result).toHaveLength(1); + }); + }); + + describe('Text', () => { + const textStroke: Stroke = { + tool: 'text', + text: 'Hello World', + position: { x: 100, y: 200 }, + fontSize: 16, + color: '#fff', + }; + + it('should erase when hitting the text area', () => { + const result = filterStrokesAfterErase([textStroke], 120, 208, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when hitting near the text position', () => { + const result = filterStrokesAfterErase([textStroke], 102, 202, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should NOT erase when far from the text', () => { + const result = filterStrokesAfterErase([textStroke], 500, 500, ERASE_RADIUS); + expect(result).toHaveLength(1); + }); + }); + + describe('Pen', () => { + const pen: Stroke = { + tool: 'pen', + points: [ + { x: 50, y: 50 }, + { x: 100, y: 100 }, + { x: 150, y: 50 }, + ], + color: '#fff', + brushSize: 2, + }; + + it('should erase when hitting a pen point', () => { + const result = filterStrokesAfterErase([pen], 100, 100, ERASE_RADIUS); + expect(result).toHaveLength(0); + }); + + it('should erase when near a pen point', () => { + const result = filterStrokesAfterErase([pen], 52, 52, 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); + }); + }); + + describe('Multiple strokes', () => { + it('should only remove the stroke that was hit', () => { + const strokes: Stroke[] = [ + { tool: 'rectangle', start: { x: 100, y: 100 }, end: { x: 200, y: 200 }, color: '#fff' }, + { tool: 'rectangle', start: { x: 400, y: 400 }, end: { x: 500, y: 500 }, color: '#fff' }, + ]; + const result = filterStrokesAfterErase(strokes, 150, 150, ERASE_RADIUS); + expect(result).toHaveLength(1); + expect(result[0].start).toEqual({ x: 400, y: 400 }); + }); + }); +}); From bec06fe068a7e3c0a84a4b68b8e59ef54e281d86 Mon Sep 17 00:00:00 2001 From: DreamTeam Mobile Date: Wed, 18 Feb 2026 18:36:25 -0800 Subject: [PATCH 6/6] Add Enter auto-indent and fix Tab first-press reliability Enter: new line starts at same indentation as current line. Tab/Shift+Tab: replaced requestAnimationFrame cursor positioning with useLayoutEffect + pendingCursorRef for reliable first-press behavior. Extracted shared applyEdit helper to reduce duplication. --- src/components/CodeEditor/CodeEditor.tsx | 73 ++++++----- src/services/code-editor-logic.ts | 15 +++ tests/unit/codeEditorAutoIndent.test.tsx | 156 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 32 deletions(-) create mode 100644 tests/unit/codeEditorAutoIndent.test.tsx diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 204f533..91c1852 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback, useEffect, ChangeEvent, KeyboardEvent } from 'react'; +import { useRef, useCallback, useEffect, useLayoutEffect, ChangeEvent, KeyboardEvent } from 'react'; import Prism from 'prismjs'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-python'; @@ -17,7 +17,7 @@ import 'prismjs/components/prism-markup-templating'; import 'prismjs/components/prism-php'; import 'prismjs/components/prism-sql'; import { useEditorStore } from '../../stores/editorStore'; -import { getPrismLanguage, dedentLines } from '../../services/code-editor-logic'; +import { getPrismLanguage, dedentLines, getLeadingWhitespace } from '../../services/code-editor-logic'; import { calculateTextOperation } from '../../services/ot-engine'; import RemoteCursors from './RemoteCursors'; @@ -30,6 +30,27 @@ export default function CodeEditor() { const inputRef = useRef(null); const highlightRef = useRef(null); const prevCodeRef = useRef(code); + const pendingCursorRef = useRef<{ start: number; end: number } | null>(null); + + // Set cursor position after React re-renders (runs before browser paint) + useLayoutEffect(() => { + if (pendingCursorRef.current && inputRef.current) { + inputRef.current.selectionStart = pendingCursorRef.current.start; + inputRef.current.selectionEnd = pendingCursorRef.current.end; + pendingCursorRef.current = null; + } + }); + + const applyEdit = useCallback( + (oldCode: string, newCode: string, cursorStart: number, cursorEnd: number) => { + calculateTextOperation(oldCode, newCode); + applyLocalOperation(); + setCode(newCode); + prevCodeRef.current = newCode; + pendingCursorRef.current = { start: cursorStart, end: cursorEnd }; + }, + [setCode, applyLocalOperation], + ); const handleInput = useCallback( (e: ChangeEvent) => { @@ -48,51 +69,39 @@ export default function CodeEditor() { const handleKeyDown = useCallback( (e: KeyboardEvent) => { + const textarea = inputRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const oldCode = textarea.value; + if (e.key === 'Tab') { e.preventDefault(); - const textarea = inputRef.current; - if (!textarea) return; - - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const oldCode = textarea.value; if (e.shiftKey) { // Shift+Tab: dedent selected lines const { text: newCode, newStart, newEnd } = dedentLines(oldCode, start, end); if (newCode !== oldCode) { - calculateTextOperation(oldCode, newCode); - applyLocalOperation(); - setCode(newCode); - prevCodeRef.current = newCode; - - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.selectionStart = newStart; - inputRef.current.selectionEnd = newEnd; - } - }); + applyEdit(oldCode, newCode, newStart, newEnd); } } else { // Tab: insert 4 spaces const spaces = ' '; const newCode = oldCode.substring(0, start) + spaces + oldCode.substring(end); - - calculateTextOperation(oldCode, newCode); - applyLocalOperation(); - setCode(newCode); - prevCodeRef.current = newCode; - - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.selectionStart = start + spaces.length; - inputRef.current.selectionEnd = start + spaces.length; - } - }); + applyEdit(oldCode, newCode, start + spaces.length, start + spaces.length); } + } else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + // Enter: auto-indent — new line starts at same indentation as current line + e.preventDefault(); + const indent = getLeadingWhitespace(oldCode, start); + const insertion = '\n' + indent; + const newCode = oldCode.substring(0, start) + insertion + oldCode.substring(end); + const newCursor = start + insertion.length; + applyEdit(oldCode, newCode, newCursor, newCursor); } }, - [setCode, applyLocalOperation], + [applyEdit], ); // Keep prevCodeRef in sync with external code changes (remote operations) diff --git a/src/services/code-editor-logic.ts b/src/services/code-editor-logic.ts index dbb66d3..c46504e 100644 --- a/src/services/code-editor-logic.ts +++ b/src/services/code-editor-logic.ts @@ -120,6 +120,21 @@ export function dedentLines( }; } +/** + * Get the leading whitespace of the line containing the cursor. + * Used for auto-indent on Enter: new line starts at the same indentation. + */ +export function getLeadingWhitespace(text: string, cursorPos: number): string { + // Find the start of the current line + const lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1; + // Extract leading whitespace + let i = lineStart; + while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { + i++; + } + return text.substring(lineStart, i); +} + // Map language identifiers to Prism grammar names // Currently all supported languages use the same identifier, // but this provides an extension point for future mismatches diff --git a/tests/unit/codeEditorAutoIndent.test.tsx b/tests/unit/codeEditorAutoIndent.test.tsx new file mode 100644 index 0000000..3e7911e --- /dev/null +++ b/tests/unit/codeEditorAutoIndent.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useEditorStore } from '../../src/stores/editorStore'; +import CodeEditor from '../../src/components/CodeEditor/CodeEditor'; +import { getLeadingWhitespace } from '../../src/services/code-editor-logic'; + +/** + * Tests for Enter auto-indent behavior in the code editor. + * + * Requirements: + * - Pressing Enter creates a new line with the same indentation as the current line + * - Works with spaces and tabs + * - No indentation if current line has none + * - Ctrl+Enter / Shift+Enter are not intercepted + */ + +describe('getLeadingWhitespace (pure function)', () => { + it('should return 4 spaces for a 4-space indented line', () => { + const text = ' hello world'; + expect(getLeadingWhitespace(text, 10)).toBe(' '); + }); + + it('should return empty string for unindented line', () => { + const text = 'hello world'; + expect(getLeadingWhitespace(text, 5)).toBe(''); + }); + + it('should return the indentation of the current line when cursor is at start', () => { + const text = ' hello'; + expect(getLeadingWhitespace(text, 0)).toBe(' '); + }); + + it('should handle multi-line text and pick the correct line', () => { + const text = 'no indent\n indented\n deep'; + // Cursor on second line (after "no indent\n") + expect(getLeadingWhitespace(text, 14)).toBe(' '); + }); + + it('should handle cursor on third deeply indented line', () => { + const text = 'no indent\n indented\n deep'; + // Cursor on third line (after "no indent\n indented\n") + expect(getLeadingWhitespace(text, 25)).toBe(' '); + }); + + it('should handle tab characters', () => { + const text = '\t\thello'; + expect(getLeadingWhitespace(text, 5)).toBe('\t\t'); + }); + + it('should handle mixed spaces and tabs', () => { + const text = '\t hello'; + expect(getLeadingWhitespace(text, 6)).toBe('\t '); + }); + + it('should return empty string for empty text', () => { + expect(getLeadingWhitespace('', 0)).toBe(''); + }); + + it('should handle line with only whitespace', () => { + const text = ' '; + expect(getLeadingWhitespace(text, 4)).toBe(' '); + }); + + it('should handle cursor at very end of indented line', () => { + const text = ' hello'; + expect(getLeadingWhitespace(text, 9)).toBe(' '); + }); +}); + +describe('Code editor Enter auto-indent integration', () => { + beforeEach(() => { + useEditorStore.getState().setCode(''); + }); + + it('should auto-indent when pressing Enter on an indented line', () => { + useEditorStore.getState().setCode(' hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + // Cursor at end of " hello" + textarea.selectionStart = 9; + textarea.selectionEnd = 9; + + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(useEditorStore.getState().code).toBe(' hello\n '); + }); + + it('should not auto-indent on unindented line', () => { + useEditorStore.getState().setCode('hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 5; + textarea.selectionEnd = 5; + + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(useEditorStore.getState().code).toBe('hello\n'); + }); + + it('should preserve deep indentation', () => { + useEditorStore.getState().setCode(' deep'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 12; + textarea.selectionEnd = 12; + + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(useEditorStore.getState().code).toBe(' deep\n '); + }); + + it('should not intercept Ctrl+Enter', () => { + useEditorStore.getState().setCode('hello'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + textarea.selectionStart = 5; + textarea.selectionEnd = 5; + + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + + // Code should NOT change (Ctrl+Enter not intercepted) + expect(useEditorStore.getState().code).toBe('hello'); + }); + + it('should auto-indent in the middle of a line', () => { + useEditorStore.getState().setCode(' hello world'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + // Cursor between "hello" and "world" + textarea.selectionStart = 9; + textarea.selectionEnd = 9; + + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(useEditorStore.getState().code).toBe(' hello\n world'); + }); + + it('should replace selected text with newline + indent', () => { + useEditorStore.getState().setCode(' hello world'); + render(); + const textarea = screen.getByLabelText('Code editor') as HTMLTextAreaElement; + + // Select "hello" + textarea.selectionStart = 4; + textarea.selectionEnd = 9; + + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(useEditorStore.getState().code).toBe(' \n world'); + }); +});